feat(web): two-stage questionnaires, screening result and diary by result

- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
  PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
  is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
  with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
  3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
  flags on weekly means against the personal norm; scheduled full scales with change
  (remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
  goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).

ADR-012.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:14:09 +03:00
co-authored by Claude Opus 5.5
parent 6e7c89900b
commit 290b0d6151
36 changed files with 1900 additions and 691 deletions
+108 -6
View File
@@ -29,7 +29,9 @@ from sqlalchemy import select
from app.api import fixtures
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import intake_enabled, load_runtime
from app.core.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply
from app.db import repo
@@ -56,6 +58,15 @@ class EmiasCodeIn(BaseModel):
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
class AnonymousIn(BaseModel):
model_config = ConfigDict(extra="forbid")
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
class EmiasDemoIn(BaseModel):
model_config = ConfigDict(extra="forbid")
persona_token: str = Field(min_length=1, max_length=64)
@@ -134,9 +145,16 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous() -> AuthOut:
"""Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the
matching onboarding instead of going straight to the menu. Without it nothing is stored at all.
"""
token = secrets.token_hex(TOKEN_LEN // 2)
if body is not None and body.focus is not None:
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]])
log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous")
@@ -170,7 +188,8 @@ class Questionnaires(BaseModel):
enabled: bool
intake: Literal["none", "active", "paused", "complete", "review"]
diary_done: bool
diary_done: bool # both parts of today's diary
diary_next: Literal["morning", "evening"] | None = None # the part still open today
class TodayOut(BaseModel):
@@ -240,7 +259,9 @@ async def my_today(token: str) -> TodayOut:
week = [week_start + timedelta(days=i) for i in range(7)]
modules = list(profile.modules or []) if profile else []
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
diary_done = any(r.kind == "diary" and r.day == diary_day and r.status == "complete" for r in records)
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
diary_done = not open_parts
def item(p: Prescription) -> PrescriptionItem:
ref = find_drug(p.drug)
@@ -258,7 +279,7 @@ async def my_today(token: str) -> TodayOut:
return TodayOut(
today=today,
now=now.strftime("%H:%M"),
has_doctor=profile is not None,
has_doctor=profile is not None and bool(profile.doctor_id),
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
next_visit=profile.next_visit if profile else None,
doses=today_doses,
@@ -268,13 +289,94 @@ async def my_today(token: str) -> TodayOut:
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
],
questionnaires=Questionnaires(
enabled=intake_enabled() and profile is not None,
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
enabled=intake_enabled(),
intake=_intake_state(records),
diary_done=diary_done,
diary_next=open_parts[0] if open_parts else None,
),
)
class ResultFinding(BaseModel):
code: str
title: str
class Conclusion(BaseModel):
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
day: date
title: str
lines: list[str]
findings: list[ResultFinding]
modules: list[str]
disclaimer: str
class HistoryItem(BaseModel):
id: str
kind: str
kind_title: str
status: str
status_title: str
day: date
started_at: datetime
completed_at: datetime | None
title: str | None # interpretation shown to the patient when the form was finished
lines: list[str]
class ResultsOut(BaseModel):
conclusion: Conclusion | None
history: list[HistoryItem]
@router.get("/me/{token}/results", response_model=ResultsOut)
async def my_results(token: str) -> ResultsOut:
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
async with get_clinical_session() as s:
records = list(
(
await s.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
)
).all()
)
t = results_texts()
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
conclusion = None
if intake and intake.details.get("interpretation"):
i = intake.details["interpretation"]
conclusion = Conclusion(
day=intake.day,
title=i["title"],
lines=i["lines"],
findings=[ResultFinding(**f) for f in i.get("findings", [])],
modules=i.get("modules", []),
disclaimer=i.get("disclaimer", t["disclaimer"]),
)
history = [
HistoryItem(
id=r.id,
kind=r.kind,
kind_title=t["kinds"].get(r.kind, r.kind),
status=r.status,
status_title=t["status"].get(r.status, r.status),
day=r.day,
started_at=r.started_at,
completed_at=r.completed_at,
title=(r.details.get("interpretation") or {}).get("title"),
lines=(r.details.get("interpretation") or {}).get("lines", []),
)
for r in records
]
return ResultsOut(conclusion=conclusion, history=history)
@router.get("/crisis/resources", response_model=list[CrisisResource])
async def crisis_resources() -> list[CrisisResource]:
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
+8 -7
View File
@@ -9,12 +9,11 @@ the versioned sources in config/.
from functools import lru_cache
from typing import Any, Literal
import yaml
from pydantic import BaseModel
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import content, texts
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
@@ -113,13 +112,15 @@ def catalog() -> Catalog:
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
diary = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
diary_items = [item for key in ("core", "ptsd", "depression", "suicide") for item in diary.get(key, [])]
diary = diary_config()
core = [item for part in DIARY_PARTS for item in diary[part]]
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
add(
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
[i["id"] for i in diary_items],
optional={i["id"] for i in diary_items if i.get("optional")},
numeric={i["id"] for i in diary_items if i.get("numeric")},
[i["id"] for i in core] + depression + ptsd,
optional={i["id"] for i in core if i.get("optional")},
numeric={i["id"] for i in core if i.get("numeric")},
)
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
+83 -34
View File
@@ -18,6 +18,7 @@ class Question:
period: str = "context"
numeric: bool = False
optional: bool = False
maximum: float = 24
def content() -> dict:
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
so a positive answer interrupts the questionnaire wherever it comes."""
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
for item in load_scale("rus_audit_s").item_ids:
if item not in answers:
return scale_question("rus_audit_s", item)
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"):
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
return None
def diary_questions(
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
) -> tuple[Question, ...]:
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
selected = ["core"]
detailed = [name for name in ("veteran", "depression") if name in modules]
if len(detailed) == 2:
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
if "veteran" in detailed:
selected.append("ptsd")
if "depression" in detailed:
selected.append("depression")
if suicide_monitoring or "depression" in modules:
selected.append("suicide")
questions = []
for section in selected:
for item in config[section]:
if item.get("weekly") and weekday != 0:
continue
questions.append(
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_week" if item.get("weekly") else "past_day",
"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)
+72 -30
View File
@@ -1,6 +1,5 @@
"""Versioned release gate and deterministic follow-up planning."""
import os
from calendar import monthrange
from datetime import date, timedelta
from pathlib import Path
@@ -11,7 +10,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.questionnaire import Approval, load_policy
from app.core.questionnaire import Approval, demo_mode, load_policy
class RuntimePolicy(BaseModel):
@@ -22,13 +21,20 @@ class RuntimePolicy(BaseModel):
content_review: Approval | None = None
contact_review: Approval | None = None
timezone: str
daily_hour: int = Field(ge=0, le=23)
mood_baseline_days: int = Field(ge=2)
mood_bad_days: int = Field(ge=1)
mood_drop: int = Field(ge=1, le=10)
morning_hour: int = Field(ge=0, le=23)
evening_hour: int = Field(ge=0, le=23)
norm_days: int = Field(ge=2)
week_days: int = Field(ge=2)
min_norm_values: int = Field(ge=1)
min_week_values: int = Field(ge=1)
mood_drop: float = Field(gt=0, le=10)
depression_item_rise: float = Field(gt=0, le=3)
ptsd_sum_rise: float = Field(gt=0, le=16)
sleep_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24)
nightmares_at: int = Field(ge=0, le=4)
alcohol_heavy_portions: float = Field(gt=0)
alcohol_heavy_days: int = Field(ge=1)
adherence_below: float = Field(gt=0, le=1)
missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...]
@@ -45,6 +51,8 @@ class RuntimePolicy(BaseModel):
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
):
raise ValueError("all route and scale intervals must be defined")
if self.morning_hour >= self.evening_hour:
raise ValueError("the morning diary must come before the evening one")
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
raise ValueError("medication days must be nonempty and strictly increasing")
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
@@ -71,7 +79,7 @@ def demo_intake() -> bool:
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
until the approvals are recorded there.
"""
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
return demo_mode()
def intake_enabled() -> bool:
@@ -125,39 +133,73 @@ def due_scales(
return tuple(sorted(due))
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
"""Consecutive calendar dates; missing data never become zero or missed doses.
def _mean(values: list[float]) -> float:
return sum(values) / len(values)
Baseline is the seven dates BEFORE the three-day deterioration window;
every baseline value is required. Nightmares OR short sleep must hold on
each of three nights. These candidate definitions require signed review.
def diary_flags(
history: dict[date, dict],
today: date,
policy: RuntimePolicy | None = None,
*,
medication_active: bool = False,
) -> tuple[str, ...]:
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
observation; trends are checked only after it. Missing data never become zero or missed doses.
"""
p = policy or load_runtime()
flags = []
flags: list[str] = []
current = history.get(today, {})
if current.get("death_thoughts") == 1:
flags.append("suicide_assessment_now")
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
baseline = [
history.get(today - timedelta(days=i), {}).get("mood")
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
]
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
mean = sum(baseline) / len(baseline)
if all(mean - x["mood"] >= p.mood_drop for x in recent):
start = min(history) if history else today
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
week_start = today - timedelta(days=p.week_days - 1)
week = [history[d] for d in sorted(history) if week_start <= d <= today]
trend = (week_start - start).days >= p.norm_days
def values(rows: list[dict], key) -> list[float]:
return [v for r in rows if (v := key(r)) is not None]
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
before, now = values(norm, key), values(week, key)
if not trend or len(before) < min_norm or len(now) < min_week:
return False
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
return change >= by
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
flags.append("phq9_due_mood")
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
for item in range(1, 10):
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
flags.append("phq9_due_depression")
break
# PTSD module: only the sum of the four items is interpreted, never single clusters.
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
def ptsd_sum(r):
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
flags.append("pcl5_due_ptsd")
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all(
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
for r in nights
):
flags.append("sleep_review")
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
flags.append("alcohol_on_medication")
if len(heavy) >= p.alcohol_heavy_days:
flags.append("rus_audit_due_alcohol")
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "none" for r in doses):
if all(r.get("medication") == "no" for r in doses):
flags.append("medication_contact")
if current.get("side_effects") == 1:
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
flags.append("adherence_review")
if current.get("medication_reason") == "side_effects":
flags.append("side_effects_review")
if current.get("medication") == "partial":
flags.append("partial_medication_review")
return tuple(flags)
+165
View File
@@ -0,0 +1,165 @@
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
"""
from typing import Any
import yaml
from app.config import get_settings
from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
PHQ9_REMISSION_BELOW = 5
PHQ9_IMPROVED_BY = 5
PCL5_RESPONSE_BY = 10
PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]:
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
def render(interpretation: dict[str, Any]) -> str:
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
parts = [interpretation["title"], *interpretation.get("lines", [])]
if interpretation.get("disclaimer"):
parts.append(interpretation["disclaimer"])
return "\n\n".join(p for p in parts if p)
def _alcohol_zone(totals: dict) -> int | None:
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
return full.get("zone") if full.get("complete") else short.get("zone")
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
"""Screening findings of stage 1, in the order they are shown."""
found = []
phq = totals.get("phq9") or {}
if phq.get("complete"):
if phq["total"] >= policy.phq9_specialist_at:
found.append("depression_suspected")
elif phq["total"] >= policy.phq9_psychologist_at:
found.append("depression_mild")
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
found.append("ptsd_suspected")
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
found.append("ptsd_signs")
zone = _alcohol_zone(totals)
if zone == 4:
found.append("alcohol_high")
elif zone in (2, 3):
found.append("alcohol_risky")
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
found.append("suicide_low")
if answers.get("tbi") == 1:
found.append("tbi")
return found
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
t = texts()
found = intake_findings(totals, answers, policy)
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
head = t["intake"]["attention" if found else "ok"]
lines = [head["text"]]
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
if decision.primary_route in t["routes"]:
lines.append(t["routes"][decision.primary_route])
names = ", ".join(t["modules"]["names"][m] for m in modules)
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
return {
"kind": "intake",
"title": head["title"],
"lines": lines,
"codes": found or ["ok"],
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
"route": decision.primary_route,
"modules": modules,
"disclaimer": t["disclaimer"],
}
def _phq9_level(total: int) -> str:
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
return band["code"]
def scale_interpretation(
kind: str,
totals: dict,
*,
baseline: int | None,
previous: int | None,
policy: IntakePolicy,
retriage_delta: int,
) -> dict:
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
t = texts()["scales"]
if kind == "rus_audit_s":
zone = _alcohol_zone(totals)
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
return {
"kind": kind,
"title": t[kind]["title"],
"lines": [line],
"codes": [f"zone_{zone}" if zone else "unknown"],
}
total = totals[kind]["total"]
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
code = _phq9_level(total) if kind == "phq9" else pcl5_level
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
change = None
if previous is not None and total - previous >= retriage_delta:
change = "worse"
elif baseline is not None:
if kind == "phq9":
if total < PHQ9_REMISSION_BELOW:
change = "remission"
elif baseline > 0 and total <= baseline / 2:
change = "response"
elif baseline - total >= PHQ9_IMPROVED_BY:
change = "improved"
else:
change = "stable"
else:
if baseline - total >= PCL5_RESPONSE_BY:
change = "response"
elif total < PCL5_IMPROVED_BELOW <= baseline:
change = "improved"
else:
change = "stable"
if change:
lines.append(t["change"][change])
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
t = texts()["diary"]
shown = [f for f in flags if f in t["flags"]]
return {
"kind": f"diary_{part}",
"title": t["saved"].format(part=t["parts"][part]),
"lines": [t["flags"][f] for f in shown],
"codes": list(flags),
}
def cssrs_interpretation(risk: str | None) -> dict:
t = texts()["cssrs"]
return {
"kind": "cssrs_since_contact",
"title": t["low" if risk == "low" else "none"],
"lines": [],
"codes": [risk or "none"],
}
+11 -1
View File
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -69,8 +70,17 @@ class IntakePolicy(BaseModel):
return self
def demo_mode() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
real policy and the release gate in config/intake_runtime.yaml are never touched.
"""
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / "questionnaire.yaml"
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+3 -1
View File
@@ -69,7 +69,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
# person has no profile at all before it.
if after is None and intake_enabled():
return "intake.start"
if after in modules:
modules = modules[modules.index(after) + 1 :]
+123 -49
View File
@@ -4,9 +4,13 @@ import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.core import interpretation
from app.core.intake_questions import (
DIARY_KINDS,
Question,
content,
cssrs_answers,
cssrs_since_contact_question,
diary_questions,
group_answers,
next_intake,
@@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import repo
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
@@ -28,21 +33,23 @@ def allowed(ctx):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
def side_effects_question():
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
def next_question(row):
a = row.answers
if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "diary":
for q in diary_questions(
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
):
if row.kind == "cssrs_since_contact":
return cssrs_since_contact_question(a)
if row.kind in DIARY_KINDS:
part = row.kind.removeprefix("diary_")
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
if q.id in a:
continue
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
continue
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
continue
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
if q.id == "medication_reason" and a.get("medication") != "no":
continue
return q
return None
@@ -63,11 +70,7 @@ def next_question(row):
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q
if row.details.get("medication_active") and "side_effects" not in a:
return next(
q
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
if q.id == "side_effects"
)
return side_effects_question()
return None
@@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
if profile:
profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required")
if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
@@ -132,7 +135,8 @@ async def advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive")
@@ -153,9 +157,6 @@ async def advance(ctx, row):
ctx.now,
params={"record_id": row.id},
)
if row.kind == "cssrs_since_contact":
# Recent and Since Last Contact are different instruments. No silent substitution.
return await manual_review(ctx, row, "cssrs_since_contact_required")
try:
q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1.
@@ -187,6 +188,8 @@ async def advance(ctx, row):
extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
await apply_modules(ctx, extra["interpretation"]["modules"])
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert(
ctx.session,
@@ -196,23 +199,29 @@ async def advance(ctx, row):
ctx.now,
params={"record_id": row.id, **extra},
)
elif row.kind == "diary":
history = {
r.day: r.answers
for r in await service.records(ctx.session, ctx.token)
if r.kind == "diary" and r.status == "complete"
}
history[row.day] = a
flags = diary_flags(history, row.day)
elif row.kind == "cssrs_since_contact":
extra["totals"] = {"cssrs_recent": asdict(c)}
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
elif row.kind in DIARY_KINDS:
# Morning and evening answers of one date form one diary day.
history: dict = {}
for r in await service.records(ctx.session, ctx.token):
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
history.setdefault(r.day, {}).update(r.answers)
history.setdefault(row.day, {}).update(a)
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
extra["flags"] = flags
for flag in flags:
await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
)
if flag == "phq9_due_mood":
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
if flag in DIARY_SCALE_TASKS:
sid = DIARY_SCALE_TASKS[flag]
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
else:
extra["totals"] = service.summary(row)
runtime = load_runtime()
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
@@ -223,27 +232,37 @@ async def advance(ctx, row):
if total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
prior = next(
(
earlier = [
r
for r in reversed(await service.records(ctx.session, ctx.token))
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))
),
None,
)
]
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")])
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)
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 start(ctx, kind)
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
@trigger("button:diary:next")
async def diary_button(ctx, event):
return await next_diary(ctx)
@state("intake")
@@ -365,12 +435,16 @@ async def collect(ctx, event: Event):
elif event.type == "text" and q.numeric:
try:
value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= 24
valid = math.isfinite(value) and 0 <= value <= q.maximum
except ValueError:
pass
elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True
if not valid:
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now)
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
return await advance(ctx, row)
+9 -3
View File
@@ -46,6 +46,8 @@ from app.llm.med_qa import fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
# A client sends this button when the person submits a free-text step without writing anything.
NO_COMMENT = "text:skip"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
@@ -378,7 +380,8 @@ def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)],
# kind="question": the engine waits for free text here (clients show an input, optional).
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref},
)
@@ -393,12 +396,15 @@ for _code in SIDE_EFFECT_CODES:
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
ref = ctx.data.get("med_se_ref")
if event.type == "button" and event.button == NO_COMMENT:
# The description is optional: «other» side effect without words.
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
ref = ctx.data.get("med_se_ref")
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
@@ -428,7 +434,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
+21 -8
View File
@@ -15,7 +15,9 @@ from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
from app.core.questionnaire import load_policy
from app.db import repo
from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask
@@ -45,16 +47,18 @@ async def plan():
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None:
continue
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
last_activity = max([*diaries, initial.day])
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
continue
if local.hour >= p.daily_hour and not any(
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
):
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
if local.hour >= hour and kind not in done:
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
last = {}
for row in rows:
if row.status != "complete":
@@ -82,11 +86,20 @@ 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
if wanted[sid]:
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
+17 -4
View File
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
sex = profile.sex if profile else None
if sex is None and prior:
sex = prior.details.get("sex")
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
row = QuestionnaireRecord(
id=uuid4().hex,
token=ctx.token,
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
version=content()["version"],
policy_version=load_policy().version,
started_at=ctx.now,
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
day=day,
status="active",
answers={},
details={
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {},
"task_id": task.id if task else None,
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
"day_index": (day - prior.day).days if prior else 0,
},
)
ctx.session.add(row)
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
return row
def save_answer(row, question, value, now):
def save_answer(row, question, value, now, *, derived=False):
"""Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day
starts = {
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
"period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(),
"source": "patient",
"derived": False,
"source": "derived" if derived else "patient",
"derived": derived,
},
},
}
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
return row
async def outstanding_task(session, token, kind):
return await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction.
+73 -178
View File
@@ -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: 'Насколько сильно сегодня Вас беспокоили:'
+17 -7
View File
@@ -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]
+21
View File
@@ -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
+4
View File
@@ -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: "За последние три месяца."
+120
View File
@@ -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: "отменён"
+6
View File
@@ -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]`) не доходят ни до пациента, ни до врача.
+30 -3
View File
@@ -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"))
+123 -56
View File
@@ -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):
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 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
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 == []
+8
View File
@@ -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(
+97 -29
View File
@@ -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"]
+4 -1
View File
@@ -4,8 +4,10 @@ import type {
CrisisResource,
DemoPersona,
DialogEvent,
Focus,
MedsReference,
Reply,
Results,
TodayPlan,
} from "./types";
@@ -41,12 +43,13 @@ const post = <T>(path: string, body?: unknown) =>
export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: () => post<AuthResult>("/auth/anonymous"),
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) =>
+32
View File
@@ -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 {
+15
View File
@@ -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 (
<div className="app app--bare">
<div className="app__main">
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
</div>
</div>
);
return (
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} />
+1
View File
@@ -28,6 +28,7 @@ const paths = {
</>
),
chevronRight: <path d="M9.5 6l6 6-6 6" />,
chevronDown: <path d="M6 9.5l6 6 6-6" />,
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
+55
View File
@@ -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<Toast[]>([]);
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 (
<Ctx.Provider value={value}>
{children}
<div className="toasts" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className="toast">
<span className="toast__icon" aria-hidden="true">
<Icon name="check" size={18} />
</span>
<p className="toast__text">{t.text}</p>
<button type="button" className="toast__close" onClick={() => close(t.id)} aria-label="Закрыть">
<Icon name="close" size={18} />
</button>
</div>
))}
</div>
</Ctx.Provider>
);
}
export function useToast(): (text: string) => void {
return useContext(Ctx);
}
+41 -26
View File
@@ -37,24 +37,22 @@ export const ru = {
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
},
welcome: {
title: "Как вы хотите войти в приложение?",
lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
title: "Вход",
emias: {
title: "Через ЕМИАС",
text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
},
anonymous: {
title: "Анонимно",
text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
howTitle: "Как это работает",
how: [
{ title: "Врач подключает", text: "На приёме вы получаете код или входите анонимно" },
{ title: "Минута в день", text: "Напоминание о лекарстве и короткая отметка самочувствия" },
{ title: "Врач видит динамику", text: "И получает сигнал, если состоянию нужно внимание" },
title: "Войти через ЕМИАС",
perks: [
"Назначения врача подгружаются автоматически",
"Врач видит ответы и получает сигнал при ухудшении",
"История доступна с любого устройства",
],
},
or: "или",
anonymous: {
title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
},
emias: {
title: "Введите код от врача",
lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.",
@@ -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: "Опрос приостановлен",
+8 -1
View File
@@ -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,6 +108,9 @@ function survey(batch: Reply[], qIndex: number): Survey | null {
? "choice"
: skip
? "text"
: // 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 };
}
+5
View File
@@ -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() {
<Route path="/meds/:ref" element={<Private><DrugInfo /></Private>} />
<Route path="/doctor" element={<Private><Doctor /></Private>} />
<Route path="/profile" element={<Private><Profile /></Private>} />
<Route path="/results" element={<Private><Results /></Private>} />
<Route path="/messages" element={<Private><Messages /></Private>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -54,9 +57,11 @@ createRoot(document.getElementById("root")!).render(
<SessionProvider>
<DialogProvider>
<HelpProvider>
<ToastProvider>
<HashRouter>
<AppRoutes />
</HashRouter>
</ToastProvider>
</HelpProvider>
</DialogProvider>
</SessionProvider>
+24 -51
View File
@@ -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() {
<AppShell signedIn={false} focus>
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="split">
<div className="split__lead">
<header className="head">
<span className="tile-icon">
<Icon name="lock" size={26} />
</span>
<div className="stack signin">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<Notice tone="warn">{t.warning}</Notice>
</div>
<div className="split__act">
<section className="panel panel--teal">
<p className="panel__title">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
{t.yesTitle}
</p>
<ul className="iconlist">
{t.yes.map((item, i) => (
<li key={item}>
<Icon name={YES_ICONS[i]} size={20} />
{item}
</li>
))}
</ul>
</section>
<section className="panel panel--red">
<p className="panel__title">
<span className="badge-icon badge-icon--red">
<Icon name="x" size={16} />
<div className="entry-grid">
{FOCUS.map(({ id, icon }) => (
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
<span className="entry__top">
<span className="tile-icon">
<Icon name={icon} size={26} />
</span>
{t.noTitle}
</p>
<ul className="iconlist">
{t.no.map((item, i) => (
<li key={item}>
<Icon name={NO_ICONS[i]} size={20} />
{item}
</li>
</span>
<span className="entry__title">
{t.focus[id].title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.focus[id].text}</span>
</button>
))}
</ul>
</section>
</div>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<button type="button" className="btn btn--primary" onClick={start} disabled={busy}>
{busy ? t.submitting : t.submit}
</button>
</div>
<p className="muted small">{busy ? t.submitting : t.note}</p>
</div>
</div>
</AppShell>
+90 -65
View File
@@ -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<Section, IconName> = {
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 (
<div className="formchip">
<span className={`chip chip--sec chip--${section}`}>
<Icon name={SECTION_ICON[section]} size={16} />
{ru.flow.sections[section]}
</span>
{/* The C-SSRS form title names suicide risk in capitals: the patient sees the section name only. */}
{title && section !== "safety" && <span className="formchip__title">{title}</span>}
</div>
);
}
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
return (
@@ -135,7 +112,8 @@ function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]>
</table>
</div>
<p className="muted small">
{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}
</p>
</section>
);
@@ -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({
<button
type="button"
className="btn btn--primary"
disabled={!text.trim() || busy}
onClick={() => onSend(text.trim())}
disabled={busy}
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
>
{ru.common.next}
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
</button>
</div>
</>
);
}
function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) => void }) {
const NUMERIC = {
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
};
function HoursInput({
busy,
onSend,
kind = "hours",
}: {
busy: boolean;
onSend: (text: string) => void;
kind?: keyof typeof NUMERIC;
}) {
const f = NUMERIC[kind];
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= 24;
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
return (
<form
className="hours"
@@ -314,7 +309,7 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
}}
>
<label htmlFor="hours" className="hours__label">
{ru.flow.hoursLabel}
{f.label}
</label>
<div className="hours__field">
<input
@@ -325,10 +320,10 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{ru.flow.hoursUnit}</span>
<span aria-hidden="true">{f.unit}</span>
</div>
<p id="hours-hint" className="muted small">
{ru.flow.hoursHint}
{f.hint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
@@ -337,13 +332,12 @@ function HoursInput({ busy, onSend }: { busy: boolean; onSend: (text: string) =>
);
}
// No form name or section above the question: the person should not see what a questionnaire screens for while
// answering it. Names and results appear only after it is finished (Results screen).
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
const item = catalog?.items[step.itemId];
const form = item ? catalog?.forms[item.form] : undefined;
return (
<>
<header className="head survey">
{form && <FormChip section={form.section} title={form.title} />}
{step.instruction.length > 0 && (
<div className="survey__instr">
<Body texts={step.instruction} />
@@ -355,12 +349,6 @@ function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }
{f}
</p>
))}
{step.asOf && (
<p className="survey__asof">
<Icon name="calendar" size={16} />
{ru.flow.asOf(dayMonth(step.asOf))}
</p>
)}
</header>
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
</>
@@ -373,12 +361,14 @@ function SurveyAct({
busy,
press,
say,
onPause,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
onPause: (b: DialogButton) => void;
}) {
let answer: ReactNode;
switch (step.input) {
@@ -386,7 +376,8 @@ function SurveyAct({
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
break;
case "hours":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} />;
case "portions":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
break;
case "text":
answer = (
@@ -410,7 +401,7 @@ function SurveyAct({
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => press(step.pause!)}>
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
@@ -422,7 +413,6 @@ function SurveyAct({
</button>
)}
</div>
<p className="muted small survey__saved">{ru.flow.saved}</p>
</>
);
}
@@ -467,25 +457,40 @@ function Breathing() {
);
}
function Done({ notes, onHome }: { notes: string[]; onHome: () => void }) {
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
// explains it. Results stay in «Мои результаты» with the date.
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
const [head = ru.flow.doneTitle, ...rest] = notes
.join("\n\n")
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{ru.flow.doneTitle}</h1>
<h1 className="h1 center">{head}</h1>
{rest.length > 0 && (
<div className="panel">
<Body texts={notes} />
<Body texts={rest} />
</div>
)}
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
<button type="button" className="btn btn--soft" onClick={onResults}>
{ru.today.results}
</button>
</div>
);
}
const SHORT_PARAGRAPHS = 3;
export function Flow() {
const navigate = useNavigate();
const toast = useToast();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
@@ -502,11 +507,36 @@ export function Flow() {
dialog.markRead();
}, [dialog.pending]);
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
// result (the conclusion of the first questionnaire) keeps its own screen.
const toasted = useRef("");
useEffect(() => {
if (step?.kind === "idle" && step.notes.length === 0) navigate("/today", { replace: true });
}, [step, navigate]);
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
const texts = step.kind === "ack" ? step.text : step.notes;
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
if (paragraphs.length && toasted.current !== stepKey) {
toasted.current = stepKey;
toast(paragraphs.join(" "));
}
navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]);
// Questionnaire: answers are saved on the server per question, and there is no going back to change one.
// The browser «back» is absorbed by a duplicate history entry of this same screen.
const testing = step?.kind === "survey" || step?.kind === "choice" || step?.kind === "consent";
useEffect(() => {
if (!testing) return;
window.history.pushState(null, "", window.location.href);
const stay = () => window.history.pushState(null, "", window.location.href);
window.addEventListener("popstate", stay);
return () => window.removeEventListener("popstate", stay);
}, [testing]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) =>
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
@@ -521,8 +551,10 @@ export function Flow() {
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "ack") {
content = <div className="loader" aria-label="Загрузка" />;
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} />;
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
@@ -556,7 +588,7 @@ export function Flow() {
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = <TopBar onBack={home}>{progress}</TopBar>;
const top = <TopBar onBack={testing ? undefined : home}>{progress}</TopBar>;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode;
let act: ReactNode;
@@ -670,20 +702,13 @@ export function Flow() {
break;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} />;
break;
case "choice": {
const form = step.formId ? catalog?.forms[step.formId] : undefined;
lead = (
<>
{form && <FormChip section={form.section} title={form.title} />}
<Head title={step.title} lead={step.lead} />
</>
);
case "choice":
lead = <Head title={step.title} lead={step.lead} />;
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
}
content = (
<div className="focus" key={stepKey}>
{top}
@@ -699,7 +724,7 @@ export function Flow() {
}
return (
<AppShell focus sos={step?.kind !== "crisis"}>
<AppShell focus bare sos={step?.kind !== "crisis"}>
{content}
</AppShell>
);
+31
View File
@@ -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<string | null>(null);
const ref = useRef<HTMLDialogElement>(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() {
<section className="stack stack--sm">
<h2 className="h3">{t.data}</h2>
<button type="button" className="row-card" onClick={() => navigate("/results")}>
<span className="tile-icon tile-icon--teal">
<Icon name="clipboard" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.result}</span>
<span className="row-card__sub">{conclusion ?? t.resultNone}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card" onClick={() => navigate("/results")}>
<span className="tile-icon">
<Icon name="book" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.history}</span>
<span className="row-card__sub">{t.historySub}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
<span className="tile-icon">
<Icon name="message" />
+118
View File
@@ -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<ResultsData | null>(null);
const [failed, setFailed] = useState(false);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
if (!session) return;
api
.results(session.token)
.then(setData)
.catch(() => setFailed(true));
}, [session]);
const c = data?.conclusion;
return (
<AppShell>
<div className="stack">
<TopBar onBack={() => navigate("/profile")} title={t.title} />
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
{!data && !failed && <div className="loader" aria-label="Загрузка" />}
{data && (
<section className="stack stack--sm" aria-labelledby="conclusion">
<h2 id="conclusion" className="h3">
{t.conclusion}
</h2>
{c ? (
<div className="result">
<p className="muted small">{t.conclusionDay(dayMonth(c.day))}</p>
<p className="result__title">{c.title}</p>
{c.findings.length > 0 && (
<div className="result__chips">
{c.findings.map((f) => (
<span key={f.code} className="chip chip--teal">
{f.title}
</span>
))}
</div>
)}
{c.lines.map((l) => (
<p key={l}>{l}</p>
))}
{c.modules.length > 0 && (
<p className="muted small">{c.modules.map((m) => t.modules[m as keyof typeof t.modules] ?? m).join(" · ")}</p>
)}
<p className="muted small">{c.disclaimer}</p>
</div>
) : (
<Notice>{t.noResult}</Notice>
)}
</section>
)}
{data && (
<section className="stack stack--sm" aria-labelledby="history">
<h2 id="history" className="h3">
{t.history}
</h2>
{data.history.length === 0 && <p className="muted">{t.empty}</p>}
<ul className="log">
{data.history.map((h) => {
const expanded = open === h.id;
return (
<li key={h.id}>
<button
type="button"
className="log__row"
aria-expanded={expanded}
onClick={() => setOpen(expanded ? null : h.id)}
disabled={!h.title && h.lines.length === 0}
>
<span className="log__date">{dayMonth(h.day)}</span>
<span className="log__text">
<span className="log__kind">{h.kind_title}</span>
<span className="log__sub">{h.title ?? h.status_title}</span>
</span>
{(h.title || h.lines.length > 0) && <Icon name={expanded ? "chevronDown" : "chevronRight"} />}
</button>
{expanded && (
<div className="log__body">
<p className="muted small">
{h.status_title}
{h.completed_at ? ` · ${new Date(h.completed_at).toLocaleString("ru-RU")}` : ""}
</p>
{h.lines.map((l) => (
<p key={l}>{l}</p>
))}
</div>
)}
</li>
);
})}
</ul>
</section>
)}
</div>
</AppShell>
);
}
+8 -11
View File
@@ -8,7 +8,6 @@ import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { SECTION_ICON } from "./Flow";
import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday";
@@ -92,7 +91,9 @@ export function Today() {
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
const waiting = classify(dialog.pending);
const hasOpenStep = waiting !== null && waiting.kind !== "idle";
// A paused questionnaire is not an open step: the «continue the questionnaire» card below resumes it.
const paused = waiting?.kind === "notice" && waiting.primary?.data === "intake:resume";
const hasOpenStep = waiting !== null && waiting.kind !== "idle" && waiting.kind !== "ack" && !paused;
const surveyItem = waiting?.kind === "survey" ? catalog?.items[waiting.itemId] : undefined;
const surveyForm = surveyItem ? catalog?.forms[surveyItem.form] : undefined;
@@ -151,13 +152,9 @@ export function Today() {
(waiting?.kind === "survey" ? (
<Task
hero
icon={surveyForm ? SECTION_ICON[surveyForm.section] : "clipboard"}
icon={surveyForm?.section === "diary" ? "book" : "clipboard"}
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
sub={
surveyForm && surveyItem
? t.surveyStep(ru.flow.sections[surveyForm.section], surveyItem.index)
: t.intakeResumeHint
}
sub={t.intakeResumeHint}
chip={t.continue}
onClick={() => navigate("/flow")}
/>
@@ -240,10 +237,10 @@ export function Today() {
{diaryDue && waiting?.kind !== "survey" && (
<Task
icon="book"
title={t.diary}
sub={t.diaryHint}
title={q.diary_next ? t.diaryPart[q.diary_next] : t.diary}
sub={q.diary_next ? t.diaryPartHint[q.diary_next] : t.diaryHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", t.diary)}
onClick={() => go("menu:checkin", q.diary_next ? t.diaryPart[q.diary_next] : t.diary)}
/>
)}
{plan && !surveys && !plan.checkin_done && (
+48 -42
View File
@@ -1,70 +1,76 @@
import { useNavigate } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { telHref, useHelp } from "../components/HelpSheet";
import { Icon, LogoMark } from "../components/Icon";
import { ru } from "../content/ru";
// A1 · Выбор способа входа
// A1 · Вход: без сайдбара, одна карточка по центру. ЕМИАС — основной путь, анонимный — по желанию
export function Welcome() {
const navigate = useNavigate();
const { resources, open } = useHelp();
const t = ru.welcome;
const emergency = resources[0];
return (
<AppShell signedIn={false}>
<div className="stack">
<header className="head">
<div className="signin-page">
<main className="signin" id="main">
<div className="signin__brand">
<LogoMark size={44} />
<span>
<span className="signin__name">{ru.product.name}</span>
<span className="signin__tag">{ru.product.tagline}</span>
</span>
</div>
<section className="signin__card">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<div className="entry-grid">
<button type="button" className="entry entry--emias" onClick={() => navigate("/emias")}>
<span className="entry__top">
<span className="tile-icon tile-icon--teal">
<Icon name="docPlus" size={26} />
</span>
<span className="chip chip--teal">{ru.common.recommended}</span>
</span>
<span className="entry__title">
{t.emias.title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.emias.text}</span>
</button>
<button type="button" className="entry" onClick={() => navigate("/anonymous")}>
<span className="entry__top">
<span className="tile-icon">
<Icon name="lock" size={26} />
</span>
</span>
<span className="entry__title">
{t.anonymous.title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.anonymous.text}</span>
</button>
</div>
<section className="how" aria-labelledby="how-title">
<h2 id="how-title" className="h3">
{t.howTitle}
</h2>
<ol className="how__list">
{t.how.map((step, i) => (
<li key={step.title} className="how__item">
<span className="how__num">{i + 1}</span>
<span>
<span className="how__title">{step.title}</span>
<span className="how__text">{step.text}</span>
</span>
<ul className="signin__perks">
{t.emias.perks.map((p) => (
<li key={p}>
<Icon name="check" size={18} />
{p}
</li>
))}
</ol>
</ul>
</button>
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
<div className="stack stack--sm">
<button type="button" className="btn btn--ghost" onClick={() => navigate("/anonymous")}>
<Icon name="lock" size={20} />
{t.anonymous.title}
</button>
<p className="muted small center">{t.anonymous.text}</p>
</div>
</section>
<p className="muted small">{t.disclaimer}</p>
<div className="signin__foot">
<button type="button" className="signin__sos" onClick={open}>
<Icon name="phone" size={18} />
{ru.help.block}
</button>
{emergency && (
<a className="signin__tel" href={telHref(emergency.phone)}>
{emergency.phone}
</a>
)}
</div>
<p className="muted small center">{t.disclaimer}</p>
</main>
</div>
</AppShell>
);
}
+274 -51
View File
@@ -634,6 +634,104 @@ button.row-card:hover:not(:disabled) {
/* ---------------------------------------------------------------- A1 entry */
.signin {
max-width: 640px;
}
/* Standalone sign-in page: no sidebar, one centred card */
.signin-page {
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: var(--s-8) var(--s-4);
background: var(--c-page-desk);
}
.signin-page .signin {
width: 100%;
max-width: 480px;
display: grid;
gap: var(--s-5);
}
.signin__brand {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-3);
}
.signin__name {
display: block;
font-size: var(--fs-18);
font-weight: 700;
color: var(--c-text);
}
.signin__tag {
display: block;
font-size: var(--fs-14);
color: var(--c-text-2);
}
.signin__card {
display: grid;
gap: var(--s-5);
padding: var(--s-8) var(--s-6);
border-radius: 24px;
background: var(--c-surface);
box-shadow: 0 12px 40px rgba(15, 39, 71, 0.08);
}
.signin__card .entry__title {
flex: 1;
}
.signin__perks {
display: grid;
gap: var(--s-2);
font-size: var(--fs-14);
color: var(--c-text-2);
}
.signin__perks li {
display: flex;
align-items: flex-start;
gap: var(--s-2);
}
.signin__perks svg {
flex: none;
margin-top: 1px;
color: var(--c-teal);
}
.signin__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
border-radius: var(--r-tile);
background: var(--c-red-soft);
}
.signin__sos {
display: inline-flex;
align-items: center;
gap: var(--s-2);
font-weight: 700;
color: var(--c-red-strong);
}
.signin__tel {
font-size: var(--fs-20);
font-weight: 700;
color: var(--c-red-strong);
text-decoration: none;
}
.entry-grid {
display: grid;
gap: var(--s-4);
@@ -1251,49 +1349,6 @@ button.row-card:hover:not(:disabled) {
justify-self: center;
}
.how {
display: grid;
gap: var(--s-3);
}
.how__list {
display: grid;
gap: var(--s-3);
}
.how__item {
display: flex;
gap: var(--s-3);
align-items: flex-start;
padding: var(--s-4);
border-radius: var(--r-tile);
background: var(--c-tile);
}
.how__num {
flex: none;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--c-navy);
color: #fff;
font-weight: 700;
font-size: var(--fs-14);
}
.how__title {
display: block;
font-weight: 700;
}
.how__text {
display: block;
font-size: var(--fs-13);
color: var(--c-text-2);
}
/* ---------------------------------------------------------------- meds */
.rx {
@@ -1972,10 +2027,6 @@ button.row-card:hover:not(:disabled) {
grid-column: 1 / -1;
}
.how__list {
grid-template-columns: repeat(3, 1fr);
}
.rail {
position: sticky;
top: var(--s-8);
@@ -2113,10 +2164,6 @@ button.row-card:hover:not(:disabled) {
border-top: 1px solid var(--c-border);
}
.survey__saved {
margin: 0;
}
.btn--sm {
width: auto;
min-height: 40px;
@@ -2433,3 +2480,179 @@ button.row-card:hover:not(:disabled) {
transition: none !important;
}
}
/* ---------------------------------------------------------------- bare: questionnaires, centred, no sidebar */
.app--bare {
background: var(--c-page-desk);
}
@media (min-width: 1024px) {
.app--bare {
display: block;
}
.app--bare .app__main {
grid-template-columns: minmax(0, 1fr);
max-width: 760px;
min-height: 100dvh;
margin: 0 auto;
padding: var(--s-10) var(--s-4);
align-content: center;
}
.app--bare .screen > .sosblock {
display: grid;
}
.app--bare .split {
grid-template-columns: minmax(0, 1fr);
gap: var(--s-6);
}
.app--bare .split__lead {
position: static;
}
}
/* ---------------------------------------------------------------- results and questionnaire log */
.result {
display: grid;
gap: var(--s-3);
padding: var(--s-5);
border-radius: var(--r-tile);
background: var(--c-teal-soft);
}
.result__title {
font-size: var(--fs-20);
font-weight: 700;
}
.result__chips {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
}
.result .chip {
background: var(--c-surface);
}
.log {
display: grid;
border: 1px solid var(--c-border);
border-radius: var(--r-tile);
background: var(--c-surface);
overflow: hidden;
}
.log > li + li {
border-top: 1px solid var(--c-border);
}
.log__row {
width: 100%;
display: flex;
align-items: center;
gap: var(--s-4);
padding: var(--s-3) var(--s-4);
text-align: left;
color: var(--c-text);
}
.log__row:disabled {
cursor: default;
}
.log__row svg {
flex: none;
color: var(--c-text-3);
}
.log__date {
flex: none;
width: 64px;
font-size: var(--fs-13);
color: var(--c-text-2);
}
.log__text {
flex: 1;
min-width: 0;
display: grid;
}
.log__kind {
font-weight: 600;
}
.log__sub {
font-size: var(--fs-13);
color: var(--c-text-2);
}
.log__body {
display: grid;
gap: var(--s-2);
padding: 0 var(--s-4) var(--s-4) calc(64px + var(--s-8));
font-size: var(--fs-14);
}
/* ---------------------------------------------------------------- toasts (top right) */
.toasts {
position: fixed;
top: calc(var(--s-4) + var(--safe-top));
right: max(var(--s-4), var(--safe-right));
left: max(var(--s-4), var(--safe-left));
z-index: 60;
display: grid;
gap: var(--s-2);
justify-items: end;
pointer-events: none;
}
.toast {
width: min(100%, 380px);
display: flex;
align-items: flex-start;
gap: var(--s-3);
padding: var(--s-3) var(--s-3) var(--s-3) var(--s-4);
border-radius: var(--r-tile);
background: var(--c-navy);
color: #fff;
box-shadow: 0 12px 32px rgb(15 39 71 / 0.25);
pointer-events: auto;
animation: toast-in 200ms var(--ease);
}
.toast__icon {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--c-teal);
}
.toast__text {
flex: 1;
margin: 3px 0 0;
font-size: var(--fs-14);
line-height: 1.4;
}
.toast__close {
flex: none;
color: rgb(255 255 255 / 0.7);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
}