feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату #79
+108
-6
@@ -29,7 +29,9 @@ from sqlalchemy import select
|
||||
from app.api import fixtures
|
||||
from app.core import clock
|
||||
from app.core.instrument_catalog import Catalog, catalog
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import intake_enabled, load_runtime
|
||||
from app.core.interpretation import texts as results_texts
|
||||
from app.core.meds_reference import find_drug, indication_for, load_reference
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
@@ -56,6 +58,15 @@ class EmiasCodeIn(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
|
||||
|
||||
|
||||
class AnonymousIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
|
||||
|
||||
|
||||
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
|
||||
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
|
||||
|
||||
|
||||
class EmiasDemoIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
persona_token: str = Field(min_length=1, max_length=64)
|
||||
@@ -134,9 +145,16 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
|
||||
|
||||
@router.post("/auth/anonymous", response_model=AuthOut)
|
||||
async def auth_anonymous() -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere."""
|
||||
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
|
||||
|
||||
With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the
|
||||
matching onboarding instead of going straight to the menu. Without it nothing is stored at all.
|
||||
"""
|
||||
token = secrets.token_hex(TOKEN_LEN // 2)
|
||||
if body is not None and body.focus is not None:
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]])
|
||||
log.info("web auth: anonymous tok=%s", token[:6])
|
||||
return AuthOut(token=token, mode="anonymous")
|
||||
|
||||
@@ -170,7 +188,8 @@ class Questionnaires(BaseModel):
|
||||
|
||||
enabled: bool
|
||||
intake: Literal["none", "active", "paused", "complete", "review"]
|
||||
diary_done: bool
|
||||
diary_done: bool # both parts of today's diary
|
||||
diary_next: Literal["morning", "evening"] | None = None # the part still open today
|
||||
|
||||
|
||||
class TodayOut(BaseModel):
|
||||
@@ -240,7 +259,9 @@ async def my_today(token: str) -> TodayOut:
|
||||
week = [week_start + timedelta(days=i) for i in range(7)]
|
||||
modules = list(profile.modules or []) if profile else []
|
||||
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
diary_done = any(r.kind == "diary" and r.day == diary_day and r.status == "complete" for r in records)
|
||||
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
|
||||
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
|
||||
diary_done = not open_parts
|
||||
|
||||
def item(p: Prescription) -> PrescriptionItem:
|
||||
ref = find_drug(p.drug)
|
||||
@@ -258,7 +279,7 @@ async def my_today(token: str) -> TodayOut:
|
||||
return TodayOut(
|
||||
today=today,
|
||||
now=now.strftime("%H:%M"),
|
||||
has_doctor=profile is not None,
|
||||
has_doctor=profile is not None and bool(profile.doctor_id),
|
||||
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
|
||||
next_visit=profile.next_visit if profile else None,
|
||||
doses=today_doses,
|
||||
@@ -268,13 +289,94 @@ async def my_today(token: str) -> TodayOut:
|
||||
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||||
],
|
||||
questionnaires=Questionnaires(
|
||||
enabled=intake_enabled() and profile is not None,
|
||||
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
|
||||
enabled=intake_enabled(),
|
||||
intake=_intake_state(records),
|
||||
diary_done=diary_done,
|
||||
diary_next=open_parts[0] if open_parts else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ResultFinding(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
|
||||
|
||||
class Conclusion(BaseModel):
|
||||
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
|
||||
|
||||
day: date
|
||||
title: str
|
||||
lines: list[str]
|
||||
findings: list[ResultFinding]
|
||||
modules: list[str]
|
||||
disclaimer: str
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_title: str
|
||||
status: str
|
||||
status_title: str
|
||||
day: date
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
title: str | None # interpretation shown to the patient when the form was finished
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class ResultsOut(BaseModel):
|
||||
conclusion: Conclusion | None
|
||||
history: list[HistoryItem]
|
||||
|
||||
|
||||
@router.get("/me/{token}/results", response_model=ResultsOut)
|
||||
async def my_results(token: str) -> ResultsOut:
|
||||
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
|
||||
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
|
||||
async with get_clinical_session() as s:
|
||||
records = list(
|
||||
(
|
||||
await s.scalars(
|
||||
select(QuestionnaireRecord)
|
||||
.where(QuestionnaireRecord.token == token)
|
||||
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
t = results_texts()
|
||||
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
|
||||
conclusion = None
|
||||
if intake and intake.details.get("interpretation"):
|
||||
i = intake.details["interpretation"]
|
||||
conclusion = Conclusion(
|
||||
day=intake.day,
|
||||
title=i["title"],
|
||||
lines=i["lines"],
|
||||
findings=[ResultFinding(**f) for f in i.get("findings", [])],
|
||||
modules=i.get("modules", []),
|
||||
disclaimer=i.get("disclaimer", t["disclaimer"]),
|
||||
)
|
||||
history = [
|
||||
HistoryItem(
|
||||
id=r.id,
|
||||
kind=r.kind,
|
||||
kind_title=t["kinds"].get(r.kind, r.kind),
|
||||
status=r.status,
|
||||
status_title=t["status"].get(r.status, r.status),
|
||||
day=r.day,
|
||||
started_at=r.started_at,
|
||||
completed_at=r.completed_at,
|
||||
title=(r.details.get("interpretation") or {}).get("title"),
|
||||
lines=(r.details.get("interpretation") or {}).get("lines", []),
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
return ResultsOut(conclusion=conclusion, history=history)
|
||||
|
||||
|
||||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||||
async def crisis_resources() -> list[CrisisResource]:
|
||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||
|
||||
@@ -9,12 +9,11 @@ the versioned sources in config/.
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.intake_questions import content, texts
|
||||
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
|
||||
from app.core.scales import load_scale
|
||||
|
||||
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
|
||||
@@ -113,13 +112,15 @@ def catalog() -> Catalog:
|
||||
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
|
||||
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
|
||||
|
||||
diary = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
diary_items = [item for key in ("core", "ptsd", "depression", "suicide") for item in diary.get(key, [])]
|
||||
diary = diary_config()
|
||||
core = [item for part in DIARY_PARTS for item in diary[part]]
|
||||
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
|
||||
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
|
||||
add(
|
||||
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
|
||||
[i["id"] for i in diary_items],
|
||||
optional={i["id"] for i in diary_items if i.get("optional")},
|
||||
numeric={i["id"] for i in diary_items if i.get("numeric")},
|
||||
[i["id"] for i in core] + depression + ptsd,
|
||||
optional={i["id"] for i in core if i.get("optional")},
|
||||
numeric={i["id"] for i in core if i.get("numeric")},
|
||||
)
|
||||
|
||||
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
|
||||
|
||||
@@ -18,6 +18,7 @@ class Question:
|
||||
period: str = "context"
|
||||
numeric: bool = False
|
||||
optional: bool = False
|
||||
maximum: float = 24
|
||||
|
||||
|
||||
def content() -> dict:
|
||||
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
|
||||
|
||||
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
|
||||
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
|
||||
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
|
||||
so a positive answer interrupts the questionnaire wherever it comes."""
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
for item in load_scale("rus_audit_s").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit_s", item)
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
if sex is None and "sex" not in answers:
|
||||
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
|
||||
for key in ("tbi", "psychotropics"):
|
||||
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
return None
|
||||
|
||||
|
||||
def diary_questions(
|
||||
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
|
||||
) -> tuple[Question, ...]:
|
||||
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
|
||||
selected = ["core"]
|
||||
detailed = [name for name in ("veteran", "depression") if name in modules]
|
||||
if len(detailed) == 2:
|
||||
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
|
||||
if "veteran" in detailed:
|
||||
selected.append("ptsd")
|
||||
if "depression" in detailed:
|
||||
selected.append("depression")
|
||||
if suicide_monitoring or "depression" in modules:
|
||||
selected.append("suicide")
|
||||
questions = []
|
||||
for section in selected:
|
||||
for item in config[section]:
|
||||
if item.get("weekly") and weekday != 0:
|
||||
continue
|
||||
questions.append(
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_week" if item.get("weekly") else "past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
def cssrs_since_contact_question(answers: dict) -> Question | None:
|
||||
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
|
||||
|
||||
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
|
||||
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
|
||||
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
|
||||
"""
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
missing = [k for k in c.missing if k != "q6_recent"]
|
||||
if not missing:
|
||||
return None
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == missing[0])
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
"since_last_contact",
|
||||
)
|
||||
|
||||
|
||||
DIARY_PARTS = ("morning", "evening")
|
||||
DIARY_KINDS = ("diary_morning", "diary_evening")
|
||||
|
||||
|
||||
def diary_config() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
|
||||
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
|
||||
|
||||
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
|
||||
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
|
||||
so diary answers never mix with the full scales.
|
||||
"""
|
||||
if part not in DIARY_PARTS:
|
||||
raise ValueError("unknown diary part")
|
||||
config = diary_config()
|
||||
questions = [
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
item.get("max", 24),
|
||||
)
|
||||
for item in config[part]
|
||||
]
|
||||
if part == "evening":
|
||||
forms = content()["forms"]
|
||||
if "depression" in modules:
|
||||
module = config["depression"]
|
||||
items = forms[module["form"]]["items"]
|
||||
per_day = module["items_per_day"]
|
||||
start = (day_index % (len(items) // per_day)) * per_day
|
||||
options = tuple((o["value"], o["label"]) for o in module["options"])
|
||||
for item in items[start : start + per_day]:
|
||||
number = item["id"].rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
|
||||
)
|
||||
if "veteran" in modules:
|
||||
module = config["ptsd"]
|
||||
form = forms[module["form"]]
|
||||
by_id = {i["id"]: i["text"] for i in form["items"]}
|
||||
options = tuple((o["value"], o["label"]) for o in form["options"])
|
||||
for item_id in module["items"]:
|
||||
number = item_id.rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
|
||||
)
|
||||
)
|
||||
questions.sort(key=lambda q: q.id != "death_thoughts")
|
||||
return tuple(questions)
|
||||
|
||||
+73
-31
@@ -1,6 +1,5 @@
|
||||
"""Versioned release gate and deterministic follow-up planning."""
|
||||
|
||||
import os
|
||||
from calendar import monthrange
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
@@ -11,7 +10,7 @@ import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.questionnaire import Approval, load_policy
|
||||
from app.core.questionnaire import Approval, demo_mode, load_policy
|
||||
|
||||
|
||||
class RuntimePolicy(BaseModel):
|
||||
@@ -22,13 +21,20 @@ class RuntimePolicy(BaseModel):
|
||||
content_review: Approval | None = None
|
||||
contact_review: Approval | None = None
|
||||
timezone: str
|
||||
daily_hour: int = Field(ge=0, le=23)
|
||||
mood_baseline_days: int = Field(ge=2)
|
||||
mood_bad_days: int = Field(ge=1)
|
||||
mood_drop: int = Field(ge=1, le=10)
|
||||
morning_hour: int = Field(ge=0, le=23)
|
||||
evening_hour: int = Field(ge=0, le=23)
|
||||
norm_days: int = Field(ge=2)
|
||||
week_days: int = Field(ge=2)
|
||||
min_norm_values: int = Field(ge=1)
|
||||
min_week_values: int = Field(ge=1)
|
||||
mood_drop: float = Field(gt=0, le=10)
|
||||
depression_item_rise: float = Field(gt=0, le=3)
|
||||
ptsd_sum_rise: float = Field(gt=0, le=16)
|
||||
sleep_bad_days: int = Field(ge=1)
|
||||
sleep_hours_below: float = Field(gt=0, le=24)
|
||||
nightmares_at: int = Field(ge=0, le=4)
|
||||
alcohol_heavy_portions: float = Field(gt=0)
|
||||
alcohol_heavy_days: int = Field(ge=1)
|
||||
adherence_below: float = Field(gt=0, le=1)
|
||||
missed_medication_days: int = Field(ge=1)
|
||||
high_risk_silence_days: int = Field(ge=1)
|
||||
medication_days: tuple[int, ...]
|
||||
@@ -45,6 +51,8 @@ class RuntimePolicy(BaseModel):
|
||||
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
|
||||
):
|
||||
raise ValueError("all route and scale intervals must be defined")
|
||||
if self.morning_hour >= self.evening_hour:
|
||||
raise ValueError("the morning diary must come before the evening one")
|
||||
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
|
||||
raise ValueError("medication days must be nonempty and strictly increasing")
|
||||
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
|
||||
@@ -71,7 +79,7 @@ def demo_intake() -> bool:
|
||||
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
|
||||
until the approvals are recorded there.
|
||||
"""
|
||||
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
return demo_mode()
|
||||
|
||||
|
||||
def intake_enabled() -> bool:
|
||||
@@ -125,39 +133,73 @@ def due_scales(
|
||||
return tuple(sorted(due))
|
||||
|
||||
|
||||
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
|
||||
"""Consecutive calendar dates; missing data never become zero or missed doses.
|
||||
def _mean(values: list[float]) -> float:
|
||||
return sum(values) / len(values)
|
||||
|
||||
Baseline is the seven dates BEFORE the three-day deterioration window;
|
||||
every baseline value is required. Nightmares OR short sleep must hold on
|
||||
each of three nights. These candidate definitions require signed review.
|
||||
|
||||
def diary_flags(
|
||||
history: dict[date, dict],
|
||||
today: date,
|
||||
policy: RuntimePolicy | None = None,
|
||||
*,
|
||||
medication_active: bool = False,
|
||||
) -> tuple[str, ...]:
|
||||
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
|
||||
|
||||
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
|
||||
observation; trends are checked only after it. Missing data never become zero or missed doses.
|
||||
"""
|
||||
p = policy or load_runtime()
|
||||
flags = []
|
||||
flags: list[str] = []
|
||||
current = history.get(today, {})
|
||||
if current.get("death_thoughts") == 1:
|
||||
flags.append("suicide_assessment_now")
|
||||
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
|
||||
baseline = [
|
||||
history.get(today - timedelta(days=i), {}).get("mood")
|
||||
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
|
||||
]
|
||||
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
|
||||
mean = sum(baseline) / len(baseline)
|
||||
if all(mean - x["mood"] >= p.mood_drop for x in recent):
|
||||
flags.append("phq9_due_mood")
|
||||
start = min(history) if history else today
|
||||
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
|
||||
week_start = today - timedelta(days=p.week_days - 1)
|
||||
week = [history[d] for d in sorted(history) if week_start <= d <= today]
|
||||
trend = (week_start - start).days >= p.norm_days
|
||||
|
||||
def values(rows: list[dict], key) -> list[float]:
|
||||
return [v for r in rows if (v := key(r)) is not None]
|
||||
|
||||
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
|
||||
before, now = values(norm, key), values(week, key)
|
||||
if not trend or len(before) < min_norm or len(now) < min_week:
|
||||
return False
|
||||
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
|
||||
return change >= by
|
||||
|
||||
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
|
||||
flags.append("phq9_due_mood")
|
||||
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
|
||||
for item in range(1, 10):
|
||||
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
|
||||
flags.append("phq9_due_depression")
|
||||
break
|
||||
# PTSD module: only the sum of the four items is interpreted, never single clusters.
|
||||
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
|
||||
|
||||
def ptsd_sum(r):
|
||||
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
|
||||
|
||||
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
|
||||
flags.append("pcl5_due_ptsd")
|
||||
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
|
||||
if all(
|
||||
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
|
||||
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
|
||||
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
|
||||
for r in nights
|
||||
):
|
||||
flags.append("sleep_review")
|
||||
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
|
||||
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
|
||||
flags.append("alcohol_on_medication")
|
||||
if len(heavy) >= p.alcohol_heavy_days:
|
||||
flags.append("rus_audit_due_alcohol")
|
||||
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
|
||||
if all(r.get("medication") == "none" for r in doses):
|
||||
if all(r.get("medication") == "no" for r in doses):
|
||||
flags.append("medication_contact")
|
||||
if current.get("side_effects") == 1:
|
||||
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
|
||||
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
|
||||
flags.append("adherence_review")
|
||||
if current.get("medication_reason") == "side_effects":
|
||||
flags.append("side_effects_review")
|
||||
if current.get("medication") == "partial":
|
||||
flags.append("partial_medication_review")
|
||||
return tuple(flags)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
|
||||
|
||||
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
|
||||
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
|
||||
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.intake_questions import content
|
||||
from app.core.questionnaire import IntakeDecision, IntakePolicy
|
||||
|
||||
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
|
||||
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
|
||||
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
|
||||
PHQ9_REMISSION_BELOW = 5
|
||||
PHQ9_IMPROVED_BY = 5
|
||||
PCL5_RESPONSE_BY = 10
|
||||
PCL5_IMPROVED_BELOW = 28
|
||||
|
||||
|
||||
def texts() -> dict[str, Any]:
|
||||
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render(interpretation: dict[str, Any]) -> str:
|
||||
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
|
||||
parts = [interpretation["title"], *interpretation.get("lines", [])]
|
||||
if interpretation.get("disclaimer"):
|
||||
parts.append(interpretation["disclaimer"])
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _alcohol_zone(totals: dict) -> int | None:
|
||||
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
|
||||
return full.get("zone") if full.get("complete") else short.get("zone")
|
||||
|
||||
|
||||
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
|
||||
"""Screening findings of stage 1, in the order they are shown."""
|
||||
found = []
|
||||
phq = totals.get("phq9") or {}
|
||||
if phq.get("complete"):
|
||||
if phq["total"] >= policy.phq9_specialist_at:
|
||||
found.append("depression_suspected")
|
||||
elif phq["total"] >= policy.phq9_psychologist_at:
|
||||
found.append("depression_mild")
|
||||
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
|
||||
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
|
||||
found.append("ptsd_suspected")
|
||||
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
|
||||
found.append("ptsd_signs")
|
||||
zone = _alcohol_zone(totals)
|
||||
if zone == 4:
|
||||
found.append("alcohol_high")
|
||||
elif zone in (2, 3):
|
||||
found.append("alcohol_risky")
|
||||
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
|
||||
found.append("suicide_low")
|
||||
if answers.get("tbi") == 1:
|
||||
found.append("tbi")
|
||||
return found
|
||||
|
||||
|
||||
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
|
||||
t = texts()
|
||||
found = intake_findings(totals, answers, policy)
|
||||
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
|
||||
head = t["intake"]["attention" if found else "ok"]
|
||||
lines = [head["text"]]
|
||||
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
|
||||
if decision.primary_route in t["routes"]:
|
||||
lines.append(t["routes"][decision.primary_route])
|
||||
names = ", ".join(t["modules"]["names"][m] for m in modules)
|
||||
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
|
||||
return {
|
||||
"kind": "intake",
|
||||
"title": head["title"],
|
||||
"lines": lines,
|
||||
"codes": found or ["ok"],
|
||||
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
|
||||
"route": decision.primary_route,
|
||||
"modules": modules,
|
||||
"disclaimer": t["disclaimer"],
|
||||
}
|
||||
|
||||
|
||||
def _phq9_level(total: int) -> str:
|
||||
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
|
||||
return band["code"]
|
||||
|
||||
|
||||
def scale_interpretation(
|
||||
kind: str,
|
||||
totals: dict,
|
||||
*,
|
||||
baseline: int | None,
|
||||
previous: int | None,
|
||||
policy: IntakePolicy,
|
||||
retriage_delta: int,
|
||||
) -> dict:
|
||||
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
|
||||
t = texts()["scales"]
|
||||
if kind == "rus_audit_s":
|
||||
zone = _alcohol_zone(totals)
|
||||
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
|
||||
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
|
||||
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
|
||||
return {
|
||||
"kind": kind,
|
||||
"title": t[kind]["title"],
|
||||
"lines": [line],
|
||||
"codes": [f"zone_{zone}" if zone else "unknown"],
|
||||
}
|
||||
total = totals[kind]["total"]
|
||||
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
|
||||
code = _phq9_level(total) if kind == "phq9" else pcl5_level
|
||||
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
|
||||
change = None
|
||||
if previous is not None and total - previous >= retriage_delta:
|
||||
change = "worse"
|
||||
elif baseline is not None:
|
||||
if kind == "phq9":
|
||||
if total < PHQ9_REMISSION_BELOW:
|
||||
change = "remission"
|
||||
elif baseline > 0 and total <= baseline / 2:
|
||||
change = "response"
|
||||
elif baseline - total >= PHQ9_IMPROVED_BY:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
else:
|
||||
if baseline - total >= PCL5_RESPONSE_BY:
|
||||
change = "response"
|
||||
elif total < PCL5_IMPROVED_BELOW <= baseline:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
if change:
|
||||
lines.append(t["change"][change])
|
||||
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
|
||||
|
||||
|
||||
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
|
||||
t = texts()["diary"]
|
||||
shown = [f for f in flags if f in t["flags"]]
|
||||
return {
|
||||
"kind": f"diary_{part}",
|
||||
"title": t["saved"].format(part=t["parts"][part]),
|
||||
"lines": [t["flags"][f] for f in shown],
|
||||
"codes": list(flags),
|
||||
}
|
||||
|
||||
|
||||
def cssrs_interpretation(risk: str | None) -> dict:
|
||||
t = texts()["cssrs"]
|
||||
return {
|
||||
"kind": "cssrs_since_contact",
|
||||
"title": t["low" if risk == "low" else "none"],
|
||||
"lines": [],
|
||||
"codes": [risk or "none"],
|
||||
}
|
||||
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -69,8 +70,17 @@ class IntakePolicy(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def demo_mode() -> bool:
|
||||
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
|
||||
|
||||
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
|
||||
real policy and the release gate in config/intake_runtime.yaml are never touched.
|
||||
"""
|
||||
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
|
||||
|
||||
def load_policy(path: Path | None = None) -> IntakePolicy:
|
||||
path = path or get_settings().config_dir / "questionnaire.yaml"
|
||||
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
|
||||
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
modules = list(profile.modules) if profile is not None and profile.modules else []
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
|
||||
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
|
||||
# person has no profile at all before it.
|
||||
if after is None and intake_enabled():
|
||||
return "intake.start"
|
||||
if after in modules:
|
||||
modules = modules[modules.index(after) + 1 :]
|
||||
|
||||
+131
-57
@@ -4,9 +4,13 @@ import math
|
||||
from dataclasses import asdict, replace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core import interpretation
|
||||
from app.core.intake_questions import (
|
||||
DIARY_KINDS,
|
||||
Question,
|
||||
content,
|
||||
cssrs_answers,
|
||||
cssrs_since_contact_question,
|
||||
diary_questions,
|
||||
group_answers,
|
||||
next_intake,
|
||||
@@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
|
||||
from app.core.scales import load_scale
|
||||
from app.core.types import AlertLevel
|
||||
from app.db import repo
|
||||
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
@@ -28,21 +33,23 @@ def allowed(ctx):
|
||||
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
|
||||
|
||||
|
||||
def side_effects_question():
|
||||
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
|
||||
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
|
||||
|
||||
|
||||
def next_question(row):
|
||||
a = row.answers
|
||||
if row.kind == "intake":
|
||||
return next_intake(a, load_policy(), row.details.get("sex"))
|
||||
if row.kind == "diary":
|
||||
for q in diary_questions(
|
||||
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
|
||||
):
|
||||
if row.kind == "cssrs_since_contact":
|
||||
return cssrs_since_contact_question(a)
|
||||
if row.kind in DIARY_KINDS:
|
||||
part = row.kind.removeprefix("diary_")
|
||||
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
|
||||
if q.id in a:
|
||||
continue
|
||||
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
|
||||
continue
|
||||
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
|
||||
continue
|
||||
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
|
||||
if q.id == "medication_reason" and a.get("medication") != "no":
|
||||
continue
|
||||
return q
|
||||
return None
|
||||
@@ -63,11 +70,7 @@ def next_question(row):
|
||||
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
|
||||
return q
|
||||
if row.details.get("medication_active") and "side_effects" not in a:
|
||||
return next(
|
||||
q
|
||||
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
|
||||
if q.id == "side_effects"
|
||||
)
|
||||
return side_effects_question()
|
||||
return None
|
||||
|
||||
|
||||
@@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
flags = list(service.decision(row).flags) if row else []
|
||||
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
|
||||
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
|
||||
flags.append("cssrs_since_contact_required")
|
||||
if row:
|
||||
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
|
||||
@@ -132,7 +135,8 @@ async def advance(ctx, row):
|
||||
a = row.answers
|
||||
c = score_cssrs_recent(cssrs_answers(a))
|
||||
policy = load_policy()
|
||||
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
|
||||
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
|
||||
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
|
||||
return await urgent(ctx, row, code="suicide_signal")
|
||||
if c.positive and policy.cssrs_positive_policy == "stop_any":
|
||||
return await urgent(ctx, row, code="cssrs_positive")
|
||||
@@ -153,9 +157,6 @@ async def advance(ctx, row):
|
||||
ctx.now,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
if row.kind == "cssrs_since_contact":
|
||||
# Recent and Since Last Contact are different instruments. No silent substitution.
|
||||
return await manual_review(ctx, row, "cssrs_since_contact_required")
|
||||
try:
|
||||
q = next_question(row)
|
||||
# Validate preserved short answers against a newly answered full-form item 1.
|
||||
@@ -187,6 +188,8 @@ async def advance(ctx, row):
|
||||
extra = {"decision": asdict(result), "totals": service.summary(row)}
|
||||
if a.get("recent_trauma") == 1:
|
||||
extra["recent_trauma"] = True
|
||||
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
|
||||
await apply_modules(ctx, extra["interpretation"]["modules"])
|
||||
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
@@ -196,23 +199,29 @@ async def advance(ctx, row):
|
||||
ctx.now,
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
elif row.kind == "diary":
|
||||
history = {
|
||||
r.day: r.answers
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.kind == "diary" and r.status == "complete"
|
||||
}
|
||||
history[row.day] = a
|
||||
flags = diary_flags(history, row.day)
|
||||
elif row.kind == "cssrs_since_contact":
|
||||
extra["totals"] = {"cssrs_recent": asdict(c)}
|
||||
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
|
||||
elif row.kind in DIARY_KINDS:
|
||||
# Morning and evening answers of one date form one diary day.
|
||||
history: dict = {}
|
||||
for r in await service.records(ctx.session, ctx.token):
|
||||
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
|
||||
history.setdefault(r.day, {}).update(r.answers)
|
||||
history.setdefault(row.day, {}).update(a)
|
||||
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
|
||||
extra["flags"] = flags
|
||||
for flag in flags:
|
||||
await service.alert(
|
||||
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
|
||||
)
|
||||
if flag == "phq9_due_mood":
|
||||
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
|
||||
if flag in DIARY_SCALE_TASKS:
|
||||
sid = DIARY_SCALE_TASKS[flag]
|
||||
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
|
||||
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
|
||||
else:
|
||||
extra["totals"] = service.summary(row)
|
||||
runtime = load_runtime()
|
||||
if row.kind in ("phq9", "pcl5"):
|
||||
sid = row.kind
|
||||
total = extra["totals"][sid]["total"]
|
||||
@@ -223,27 +232,37 @@ async def advance(ctx, row):
|
||||
if total >= priority
|
||||
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
|
||||
)
|
||||
prior = next(
|
||||
(
|
||||
r
|
||||
for r in reversed(await service.records(ctx.session, ctx.token))
|
||||
if r.id != row.id
|
||||
and r.status == "complete"
|
||||
and r.version == row.version
|
||||
and r.details.get("totals", {}).get(sid, {}).get("complete")
|
||||
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
|
||||
),
|
||||
None,
|
||||
)
|
||||
earlier = [
|
||||
r
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.id != row.id
|
||||
and r.status == "complete"
|
||||
and r.version == row.version
|
||||
and r.details.get("totals", {}).get(sid, {}).get("complete")
|
||||
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
|
||||
]
|
||||
prior = earlier[-1] if earlier else None
|
||||
if prior:
|
||||
change = total - prior.details["totals"][sid]["total"]
|
||||
extra["delta"] = {
|
||||
"baseline_record": prior.id,
|
||||
"value": change,
|
||||
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
|
||||
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
|
||||
}
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
sid,
|
||||
extra["totals"],
|
||||
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
|
||||
previous=prior.details["totals"][sid]["total"] if prior else None,
|
||||
policy=policy,
|
||||
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
|
||||
)
|
||||
if row.kind == "rus_audit_s" and not row.details.get("sex"):
|
||||
return await manual_review(ctx, row, "alcohol_sex_unknown")
|
||||
if row.kind == "rus_audit_s":
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
|
||||
)
|
||||
if extra["totals"]["rus_audit"].get("zone") == 4:
|
||||
extra["parallel_routes"] = ["narcologist"]
|
||||
retriage = extra.get("delta", {}).get("retriage", False)
|
||||
@@ -257,13 +276,48 @@ async def advance(ctx, row):
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
await service.finish(ctx, row, "complete", extra=extra)
|
||||
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
|
||||
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
|
||||
# The risk questions open the evening diary; continue straight into it.
|
||||
step = await start(ctx, "diary_evening")
|
||||
step.replies.insert(0, Reply(text=shown, kind="info"))
|
||||
return step
|
||||
from app.dialog.flows.core import menu_reply
|
||||
|
||||
replies = [menu_reply(ctx, texts()["done"])]
|
||||
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
|
||||
if alcohol_total.get("zone") in (2, 3):
|
||||
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
|
||||
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
|
||||
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
|
||||
|
||||
|
||||
# Diary flags that bring a full scale forward («Этап 2», p.4).
|
||||
DIARY_SCALE_TASKS = {
|
||||
"phq9_due_mood": "phq9",
|
||||
"phq9_due_depression": "phq9",
|
||||
"pcl5_due_ptsd": "pcl5",
|
||||
"rus_audit_due_alcohol": "rus_audit_s",
|
||||
}
|
||||
|
||||
|
||||
async def apply_modules(ctx, modules):
|
||||
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
|
||||
(diagnosis from ЕМИАС) are kept and only extended."""
|
||||
profile = await ctx.profile()
|
||||
if profile is None:
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
|
||||
return
|
||||
current = list(profile.modules or [])
|
||||
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
|
||||
|
||||
|
||||
def local_day(ctx):
|
||||
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
|
||||
|
||||
async def done_today(ctx, kind):
|
||||
day = local_day(ctx)
|
||||
return any(
|
||||
r.kind == kind and r.day == day and r.status == "complete"
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
)
|
||||
|
||||
|
||||
async def start(ctx, kind="intake", task=None):
|
||||
@@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None):
|
||||
if task:
|
||||
return Step()
|
||||
return Step(replies=[Reply(text=texts()["review"], kind="info")])
|
||||
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
|
||||
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
|
||||
row = await service.create_record(ctx, kind, task=task)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -316,14 +372,28 @@ async def checkin(ctx, event):
|
||||
existing = await service.records(ctx.session, ctx.token)
|
||||
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
|
||||
return await start(ctx)
|
||||
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
|
||||
return Step(replies=[Reply(text=texts()["done"], kind="info")])
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
if risk_task.status in ("pending", "active"):
|
||||
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, "diary")
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
async def next_diary(ctx):
|
||||
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
|
||||
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
|
||||
day = local_day(ctx)
|
||||
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
|
||||
for kind in DIARY_KINDS:
|
||||
if kind in done:
|
||||
continue
|
||||
profile = await ctx.profile()
|
||||
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, kind)
|
||||
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
|
||||
|
||||
|
||||
@trigger("button:diary:next")
|
||||
async def diary_button(ctx, event):
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
@state("intake")
|
||||
@@ -365,12 +435,16 @@ async def collect(ctx, event: Event):
|
||||
elif event.type == "text" and q.numeric:
|
||||
try:
|
||||
value = float(str(ctx.text).replace(",", "."))
|
||||
valid = math.isfinite(value) and 0 <= value <= 24
|
||||
valid = math.isfinite(value) and 0 <= value <= q.maximum
|
||||
except ValueError:
|
||||
pass
|
||||
elif event.type == "text" and q.optional:
|
||||
value, valid = str(ctx.text)[:2000], True
|
||||
if not valid:
|
||||
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
|
||||
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
|
||||
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
|
||||
service.save_answer(row, q, value, ctx.now)
|
||||
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
|
||||
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
|
||||
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -46,6 +46,8 @@ from app.llm.med_qa import fallback_text, med_qa
|
||||
|
||||
QUESTION_STATE = "meds.question"
|
||||
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
|
||||
# A client sends this button when the person submits a free-text step without writing anything.
|
||||
NO_COMMENT = "text:skip"
|
||||
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
|
||||
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
|
||||
|
||||
@@ -378,7 +380,8 @@ def _side_effect_code(code: str) -> Any:
|
||||
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
if code == "other":
|
||||
return Step(
|
||||
replies=[Reply(text=load_meds_texts().side_effects.describe)],
|
||||
# kind="question": the engine waits for free text here (clients show an input, optional).
|
||||
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
|
||||
next_state=SIDE_EFFECT_TEXT_STATE,
|
||||
data={"med_se_ref": ref},
|
||||
)
|
||||
@@ -393,12 +396,15 @@ for _code in SIDE_EFFECT_CODES:
|
||||
|
||||
@state(SIDE_EFFECT_TEXT_STATE)
|
||||
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
if event.type == "button" and event.button == NO_COMMENT:
|
||||
# The description is optional: «other» side effect without words.
|
||||
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
|
||||
if event.type != "text" or ctx.text is None:
|
||||
return await leave_to_idle(ctx, event, {"med_se_ref": None})
|
||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
|
||||
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
|
||||
|
||||
|
||||
@@ -428,7 +434,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step:
|
||||
t = load_meds_texts().question
|
||||
if await current_prescription(ctx) is None:
|
||||
return Step(replies=[menu_reply(ctx, t.no_prescription)])
|
||||
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
|
||||
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
|
||||
|
||||
|
||||
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
|
||||
|
||||
+22
-9
@@ -15,7 +15,9 @@ from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import load_policy
|
||||
from app.db import repo
|
||||
from app.db.clinical import PatientProfile
|
||||
from app.db.intake import QuestionnaireTask
|
||||
@@ -45,16 +47,18 @@ async def plan():
|
||||
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
|
||||
if initial is None:
|
||||
continue
|
||||
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
|
||||
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
|
||||
last_activity = max([*diaries, initial.day])
|
||||
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
|
||||
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
|
||||
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
|
||||
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
|
||||
continue
|
||||
if local.hour >= p.daily_hour and not any(
|
||||
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
|
||||
):
|
||||
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
|
||||
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
|
||||
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
|
||||
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
|
||||
if local.hour >= hour and kind not in done:
|
||||
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
|
||||
last = {}
|
||||
for row in rows:
|
||||
if row.status != "complete":
|
||||
@@ -82,12 +86,21 @@ async def plan():
|
||||
after_crisis=crisis_date,
|
||||
policy=p,
|
||||
)
|
||||
if scales or local.hour >= p.daily_hour:
|
||||
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
|
||||
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
|
||||
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
|
||||
totals = initial.details.get("totals", {})
|
||||
modules = set(profile.modules or [])
|
||||
policy = load_policy()
|
||||
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
|
||||
wanted = {
|
||||
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
|
||||
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
|
||||
"rus_audit_s": True,
|
||||
}
|
||||
for sid in scales:
|
||||
if sid == "pcl5" and initial.answers.get("trauma") != 1:
|
||||
continue
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
if wanted[sid]:
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
|
||||
|
||||
async def deliver():
|
||||
|
||||
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
sex = profile.sex if profile else None
|
||||
if sex is None and prior:
|
||||
sex = prior.details.get("sex")
|
||||
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
|
||||
row = QuestionnaireRecord(
|
||||
id=uuid4().hex,
|
||||
token=ctx.token,
|
||||
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
version=content()["version"],
|
||||
policy_version=load_policy().version,
|
||||
started_at=ctx.now,
|
||||
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
|
||||
day=day,
|
||||
status="active",
|
||||
answers={},
|
||||
details={
|
||||
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
|
||||
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
|
||||
"answer_metadata": {},
|
||||
"task_id": task.id if task else None,
|
||||
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
|
||||
"day_index": (day - prior.day).days if prior else 0,
|
||||
},
|
||||
)
|
||||
ctx.session.add(row)
|
||||
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
return row
|
||||
|
||||
|
||||
def save_answer(row, question, value, now):
|
||||
def save_answer(row, question, value, now, *, derived=False):
|
||||
"""Anchor recall windows to administration, never to the day of a resumed answer."""
|
||||
day = row.day
|
||||
starts = {
|
||||
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
|
||||
"period": question.period,
|
||||
"period_start": starts[question.period].isoformat() if question.period in starts else None,
|
||||
"period_end": day.isoformat(),
|
||||
"source": "patient",
|
||||
"derived": False,
|
||||
"source": "derived" if derived else "patient",
|
||||
"derived": derived,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
|
||||
return row
|
||||
|
||||
|
||||
async def outstanding_task(session, token, kind):
|
||||
return await session.scalar(
|
||||
select(QuestionnaireTask).where(
|
||||
QuestionnaireTask.token == token,
|
||||
QuestionnaireTask.kind == kind,
|
||||
QuestionnaireTask.status.in_(("pending", "active")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def lock_patient(session, token):
|
||||
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
|
||||
# use the engine's in-process lock. Consent and answers share this transaction.
|
||||
|
||||
+73
-178
@@ -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: 'Насколько сильно сегодня Вас беспокоили:'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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: "За последние три месяца."
|
||||
|
||||
@@ -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: "отменён"
|
||||
@@ -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]`) не доходят ни до пациента, ни до врача.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+125
-58
@@ -94,6 +94,17 @@ async def complete_intake(e, choices=None):
|
||||
raise AssertionError("intake did not terminate")
|
||||
|
||||
|
||||
async def to_cssrs(e, choices=None):
|
||||
"""Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S."""
|
||||
replies = await press(e, "intake:start")
|
||||
for _ in range(20):
|
||||
button = answer_button(replies, {"trauma": 1, **(choices or {})})
|
||||
if ":cssrs_" in button:
|
||||
return replies
|
||||
replies = await press(e, button)
|
||||
raise AssertionError("C-SSRS not reached")
|
||||
|
||||
|
||||
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
(row,) = await rows()
|
||||
@@ -111,13 +122,13 @@ async def test_pause_restart_and_duplicate_callbacks(released):
|
||||
old = answer_button(replies)
|
||||
await asyncio.gather(press(released, old), press(released, old))
|
||||
(row,) = await rows()
|
||||
assert row.answers == {"cssrs_q1": 0}
|
||||
assert row.answers == {"phq9_1": 0}
|
||||
await press(released, "intake:pause")
|
||||
restarted = Engine(gateway=LLMGateway(MockTasks()))
|
||||
replies = await press(restarted, "intake:resume")
|
||||
assert "cssrs_q2" in answer_button(replies)
|
||||
assert "phq9_2" in answer_button(replies)
|
||||
await press(restarted, old)
|
||||
assert (await rows())[0].answers == {"cssrs_q1": 0}
|
||||
assert (await rows())[0].answers == {"phq9_1": 0}
|
||||
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
|
||||
|
||||
|
||||
@@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
|
||||
if signal == "help":
|
||||
replies = await press(released, "intake:help")
|
||||
elif signal == "cssrs":
|
||||
replies = await to_cssrs(released)
|
||||
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
|
||||
replies = await press(released, answer_button(replies, {item: 0}))
|
||||
else:
|
||||
@@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released):
|
||||
|
||||
|
||||
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
|
||||
replies = await press(released, "intake:start")
|
||||
replies = await to_cssrs(released)
|
||||
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
|
||||
replies = await press(released, answer_button(replies))
|
||||
replies = await press(released, answer_button(replies))
|
||||
assert any(r.kind == "safety_plan" for r in replies)
|
||||
assert "phq9_1" in answer_button(replies)
|
||||
# The screener ends stage 1; the context questions follow (sex is known from the profile).
|
||||
assert ":tbi:" in answer_button(replies)
|
||||
|
||||
|
||||
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
|
||||
@@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
|
||||
monkeypatch.setattr(integrations, "outbox", Failing())
|
||||
await jobs.run_until_now()
|
||||
tasks = await rows(QuestionnaireTask)
|
||||
assert len(tasks) == 2
|
||||
risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
|
||||
assert risk.delivery and risk.sent_at is None
|
||||
# 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS.
|
||||
assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"}
|
||||
morning = next(t for t in tasks if t.kind == "diary_morning")
|
||||
assert morning.delivery and morning.sent_at is None
|
||||
outbox = MemoryOutbox()
|
||||
monkeypatch.setattr(integrations, "outbox", outbox)
|
||||
await jobs.run_until_now()
|
||||
@@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
|
||||
assert len(await rows()) == 2
|
||||
|
||||
|
||||
async def test_missing_since_contact_is_manual_review_not_recent(released):
|
||||
async def test_since_contact_risk_check_opens_the_evening_diary(released):
|
||||
async with session.get_clinical_session() as s:
|
||||
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
|
||||
task_id = task.id
|
||||
replies = await released.handle(
|
||||
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
|
||||
)
|
||||
(row,) = await rows()
|
||||
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
|
||||
assert row.details["stop_reason"] == "cssrs_since_contact_required"
|
||||
assert not answer_button(replies)
|
||||
assert any("последнего ответа" in r.text for r in replies)
|
||||
for _ in range(5):
|
||||
button = answer_button(replies)
|
||||
if ":cssrs_" not in (button or ""):
|
||||
break
|
||||
replies = await press(released, button)
|
||||
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
|
||||
assert risk.status == "complete" and risk.answers == {
|
||||
"cssrs_q1": 0,
|
||||
"cssrs_q2": 0,
|
||||
"cssrs_q6_lifetime": 0,
|
||||
"cssrs_q6_recent": 0,
|
||||
}
|
||||
assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True
|
||||
assert ":mood:" in answer_button(replies)
|
||||
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
|
||||
|
||||
|
||||
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
|
||||
@@ -256,6 +282,20 @@ async def review_risk_record(e):
|
||||
)
|
||||
|
||||
|
||||
async def run_diary(e, replies, choices=None, numbers=("6,5", "2")):
|
||||
"""Answers the open diary questions; numeric items take `numbers` in turn."""
|
||||
numbers = list(numbers)
|
||||
for _ in range(30):
|
||||
button = answer_button(replies, choices)
|
||||
if button:
|
||||
replies = await press(e, button)
|
||||
elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers:
|
||||
replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0)))
|
||||
else:
|
||||
return replies
|
||||
raise AssertionError("diary did not terminate")
|
||||
|
||||
|
||||
async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
from app.db.audit import AuditLog
|
||||
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
await jobs.run_until_now()
|
||||
async with session.get_clinical_session() as s:
|
||||
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
|
||||
replies = await run_diary(released, await press(released, "menu:checkin"))
|
||||
# Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist.
|
||||
replies = await press(released, "menu:checkin")
|
||||
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0})
|
||||
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
|
||||
assert risk.status == "review_required"
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await review(
|
||||
TOKEN,
|
||||
@@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
|
||||
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
|
||||
await review_risk_record(released)
|
||||
await jobs.run_until_now()
|
||||
assert any(r.kind == "diary" and r.status == "active" for r in await rows())
|
||||
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
|
||||
await history(TOKEN, "synthetic-doctor")
|
||||
audits = await rows(AuditLog)
|
||||
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
|
||||
|
||||
|
||||
async def test_diary_numeric_skip_and_not_prescribed(released):
|
||||
async def test_diary_morning_then_evening_with_the_ptsd_module(released):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
await press(released, "menu:checkin")
|
||||
await review_risk_record(released)
|
||||
replies = await press(released, "menu:checkin")
|
||||
for _ in range(30):
|
||||
q = next(
|
||||
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
|
||||
None,
|
||||
)
|
||||
if q == "comment":
|
||||
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
|
||||
replies = await press(released, button)
|
||||
elif q:
|
||||
replies = await press(released, answer_button(replies, {"medication": 3}))
|
||||
elif any("часов" in r.text for r in replies):
|
||||
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
|
||||
assert any("число часов" in r.text for r in invalid)
|
||||
replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
|
||||
else:
|
||||
break
|
||||
diary = next(r for r in await rows() if r.kind == "diary")
|
||||
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
|
||||
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
|
||||
assert diary.answers["comment"] is None
|
||||
assert any("часов" in r.text for r in replies)
|
||||
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
|
||||
assert any("число часов" in r.text for r in invalid)
|
||||
replies = await run_diary(released, invalid, {"sleep_quality": 3})
|
||||
assert any("утро" in r.text for r in replies)
|
||||
morning = next(r for r in await rows() if r.kind == "diary_morning")
|
||||
assert morning.status == "complete"
|
||||
assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0}
|
||||
replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2})
|
||||
evening = next(r for r in await rows() if r.kind == "diary_evening")
|
||||
assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed"
|
||||
assert "medication_reason" not in evening.answers
|
||||
assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers)
|
||||
assert evening.details["interpretation"]["kind"] == "diary_evening"
|
||||
replies = await press(released, "menu:checkin")
|
||||
assert any("уже заполнен" in r.text for r in replies)
|
||||
|
||||
|
||||
async def test_high_risk_diary_asks_safety_first_and_stops(released):
|
||||
async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
async with session.get_clinical_session() as s:
|
||||
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
|
||||
await press(released, "menu:checkin")
|
||||
await review_risk_record(released)
|
||||
replies = await press(released, "menu:checkin")
|
||||
button = answer_button(replies)
|
||||
assert "death_thoughts" in button
|
||||
replies = await press(released, button)
|
||||
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
|
||||
await run_diary(released, await press(released, "menu:checkin"))
|
||||
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
|
||||
assert any(r.kind == "crisis" for r in replies)
|
||||
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
|
||||
diary = next(r for r in await rows() if r.kind == "diary")
|
||||
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
|
||||
evening = next(r for r in await rows() if r.kind == "diary_evening")
|
||||
assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
|
||||
|
||||
|
||||
async def test_unknown_sex_goes_to_review_without_a_zone(released):
|
||||
@@ -344,7 +380,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
|
||||
path.write_text(yaml.safe_dump(data, allow_unicode=True))
|
||||
await press(released, "intake:resume")
|
||||
(row,) = await rows()
|
||||
assert row.status == "review_required" and row.answers == {"cssrs_q1": 0}
|
||||
assert row.status == "review_required" and row.answers == {"phq9_1": 0}
|
||||
|
||||
|
||||
async def test_followup_delta_uses_comparable_complete_baseline(released):
|
||||
@@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released):
|
||||
data = yaml.safe_load(path.read_text())
|
||||
data["cssrs_positive_policy"] = "stop_any"
|
||||
path.write_text(yaml.safe_dump(data, allow_unicode=True))
|
||||
replies = await press(released, "intake:start")
|
||||
replies = await to_cssrs(released)
|
||||
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
|
||||
assert any(r.kind == "crisis" for r in replies)
|
||||
assert (await rows())[0].answers == {"cssrs_q1": 1}
|
||||
assert (await rows())[0].answers["cssrs_q1"] == 1
|
||||
|
||||
|
||||
async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
|
||||
async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
|
||||
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59))
|
||||
await jobs.plan()
|
||||
assert await rows(QuestionnaireTask) == []
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
|
||||
await jobs.plan()
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"}
|
||||
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0))
|
||||
await jobs.plan()
|
||||
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"}
|
||||
|
||||
|
||||
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
|
||||
@@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch):
|
||||
replies = await press(released, "intake:resume")
|
||||
await press(released, answer_button(replies))
|
||||
(record,) = await rows()
|
||||
metadata = record.details["answer_metadata"]["cssrs_q2"]
|
||||
assert metadata["period_start"] == "2026-09-03"
|
||||
metadata = record.details["answer_metadata"]["phq9_2"]
|
||||
assert metadata["period_start"] == "2026-09-19"
|
||||
assert metadata["period_end"] == "2026-10-03"
|
||||
assert metadata["answered_at"].startswith("2026-10-04")
|
||||
|
||||
|
||||
async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released):
|
||||
from app.api.patient_web import my_results
|
||||
|
||||
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
|
||||
choices.update(trauma=1, tbi=1, psychotropics=1)
|
||||
replies = await complete_intake(released, choices)
|
||||
assert any("Предположение депрессии" in r.text for r in replies)
|
||||
assert any("не диагноз" in r.text for r in replies)
|
||||
(row,) = await rows()
|
||||
interpretation = row.details["interpretation"]
|
||||
assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"]
|
||||
async with session.get_clinical_session() as s:
|
||||
# A doctor's modules (diagnosis) stay; the screening only adds to them.
|
||||
assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"]
|
||||
results = await my_results(TOKEN)
|
||||
assert results.conclusion.title == interpretation["title"]
|
||||
assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден"
|
||||
|
||||
|
||||
async def test_without_a_doctor_the_screening_defines_the_modules(released):
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
|
||||
replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
|
||||
assert any("не выявлено" in r.text for r in replies)
|
||||
async with session.get_clinical_session() as s:
|
||||
assert (await repo.get_profile(s, TOKEN)).modules == []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+39
-24
@@ -37,23 +37,21 @@ export const ru = {
|
||||
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
|
||||
},
|
||||
welcome: {
|
||||
title: "Как вы хотите войти в приложение?",
|
||||
lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.",
|
||||
title: "Вход",
|
||||
emias: {
|
||||
title: "Через ЕМИАС",
|
||||
text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.",
|
||||
title: "Войти через ЕМИАС",
|
||||
perks: [
|
||||
"Назначения врача подгружаются автоматически",
|
||||
"Врач видит ответы и получает сигнал при ухудшении",
|
||||
"История доступна с любого устройства",
|
||||
],
|
||||
},
|
||||
or: "или",
|
||||
anonymous: {
|
||||
title: "Анонимно",
|
||||
text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.",
|
||||
title: "Войти анонимно",
|
||||
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
|
||||
},
|
||||
disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.",
|
||||
howTitle: "Как это работает",
|
||||
how: [
|
||||
{ title: "Врач подключает", text: "На приёме вы получаете код или входите анонимно" },
|
||||
{ title: "Минута в день", text: "Напоминание о лекарстве и короткая отметка самочувствия" },
|
||||
{ title: "Врач видит динамику", text: "И получает сигнал, если состоянию нужно внимание" },
|
||||
],
|
||||
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
|
||||
},
|
||||
emias: {
|
||||
title: "Введите код от врача",
|
||||
@@ -70,14 +68,12 @@ export const ru = {
|
||||
demoDay: (day: number) => `${day}-й день терапии`,
|
||||
},
|
||||
anonymous: {
|
||||
title: "Анонимный вход",
|
||||
lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.",
|
||||
yesTitle: "Что будет доступно:",
|
||||
yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"],
|
||||
noTitle: "Чего не будет:",
|
||||
no: ["Врач не увидит ответы", "История только на этом устройстве"],
|
||||
warning: "После выхода историю восстановить нельзя.",
|
||||
submit: "Продолжить анонимно",
|
||||
title: "Что вы хотите проверить?",
|
||||
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.",
|
||||
focus: {
|
||||
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" },
|
||||
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" },
|
||||
},
|
||||
submitting: "Создаём профиль…",
|
||||
},
|
||||
today: {
|
||||
@@ -105,7 +101,6 @@ export const ru = {
|
||||
newMessage: "Новое сообщение",
|
||||
open: "Открыть",
|
||||
continue: "Продолжить",
|
||||
surveyStep: (section: string, n: number) => `${section} · вопрос ${n}`,
|
||||
intakeStart: "Первичный опрос",
|
||||
intakeStartHint: "15–20 минут, можно прерваться и продолжить позже",
|
||||
intakeResume: "Продолжить первичный опрос",
|
||||
@@ -115,6 +110,9 @@ export const ru = {
|
||||
intakeDone: "Первичный опрос пройден",
|
||||
diary: "Дневник самочувствия",
|
||||
diaryHint: "2–3 минуты: настроение, сон, лекарства",
|
||||
diaryPart: { morning: "Дневник — утро", evening: "Дневник — вечер" },
|
||||
diaryPartHint: { morning: "3 вопроса: сон и алкоголь", evening: "Настроение, лекарства и модули по результату опроса" },
|
||||
results: "Мой результат",
|
||||
diaryDone: "Дневник заполнен",
|
||||
therapyDay: (day: number) => `День ${day} лечения`,
|
||||
effectHint: "Когда ждать эффекта и что важно знать",
|
||||
@@ -187,6 +185,20 @@ export const ru = {
|
||||
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
|
||||
logout: "Выйти",
|
||||
messages: "Сообщения",
|
||||
result: "Предварительный результат",
|
||||
resultNone: "Появится после первичного опроса.",
|
||||
history: "История опросов",
|
||||
historySub: "Все опросы и дневники с датами и результатами",
|
||||
},
|
||||
results: {
|
||||
title: "Мои результаты",
|
||||
conclusion: "Предварительный результат",
|
||||
conclusionDay: (d: string) => `Первичный опрос от ${d}`,
|
||||
modules: { depression: "Модуль «Депрессия»", veteran: "Модуль «ПТСР»" },
|
||||
history: "История опросов",
|
||||
empty: "Здесь появятся пройденные опросы.",
|
||||
noResult: "Пройдите первичный опрос — после него здесь будет результат.",
|
||||
toIntake: "Пройти первичный опрос",
|
||||
},
|
||||
flow: {
|
||||
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
|
||||
@@ -194,6 +206,8 @@ export const ru = {
|
||||
textPlaceholder: "Напишите несколько слов",
|
||||
sending: "Отправляем…",
|
||||
doneTitle: "Готово",
|
||||
resultTitle: "Результат",
|
||||
sendEmpty: "Отправить без комментария",
|
||||
doneLead: "Врач увидит отметку.",
|
||||
breathe: "Вдох на 4 — выдох на 6",
|
||||
breatheIn: "Вдох",
|
||||
@@ -214,11 +228,12 @@ export const ru = {
|
||||
},
|
||||
questionN: (n: number) => `Вопрос ${n}`,
|
||||
recall: (period: string) => `Период: ${period}`,
|
||||
asOf: (date: string) => `Считайте от ${date}`,
|
||||
saved: "Ответы сохраняются после каждого вопроса",
|
||||
hoursLabel: "Сколько часов",
|
||||
hoursUnit: "ч",
|
||||
hoursHint: "От 0 до 24, можно с половиной: 6,5",
|
||||
portionsLabel: "Сколько порций",
|
||||
portionsUnit: "порц.",
|
||||
portionsHint: "Если не пили — 0. Можно дробное: 1,5",
|
||||
drinks: "Таблица стандартных порций",
|
||||
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
|
||||
noticeTitle: "Опрос приостановлен",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { DialogButton, Reply } from "../api/types";
|
||||
|
||||
export type Step =
|
||||
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
|
||||
| { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home
|
||||
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
|
||||
| { kind: "crisis"; text: string; buttons: DialogButton[] }
|
||||
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
|
||||
@@ -26,7 +27,7 @@ export interface Survey {
|
||||
title: string; // the item text, verbatim
|
||||
footnotes: string[]; // «*…» explanations printed under the item on the paper form
|
||||
options: DialogButton[];
|
||||
input: "choice" | "number" | "hours" | "text";
|
||||
input: "choice" | "number" | "hours" | "portions" | "text";
|
||||
anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item
|
||||
asOf?: string; // ISO date the recall period counts from
|
||||
skip?: DialogButton;
|
||||
@@ -107,7 +108,10 @@ function survey(batch: Reply[], qIndex: number): Survey | null {
|
||||
? "choice"
|
||||
: skip
|
||||
? "text"
|
||||
: "hours";
|
||||
: // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours.
|
||||
/порци/i.test(title)
|
||||
? "portions"
|
||||
: "hours";
|
||||
return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause };
|
||||
}
|
||||
|
||||
@@ -142,6 +146,9 @@ export function classify(batch: Reply[]): Step | null {
|
||||
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
|
||||
const texts = batch.map((r) => r.text);
|
||||
if (qIndex === -1) {
|
||||
// Without buttons the engine waits for free text only when it marks the reply as a question; anything else
|
||||
// («отметил приём») is a confirmation.
|
||||
if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) };
|
||||
const { lead, title } = titled(texts);
|
||||
return { kind: "text", lead, title };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<section className="panel panel--red">
|
||||
<p className="panel__title">
|
||||
<span className="badge-icon badge-icon--red">
|
||||
<Icon name="x" size={16} />
|
||||
</span>
|
||||
{t.noTitle}
|
||||
</p>
|
||||
<ul className="iconlist">
|
||||
{t.no.map((item, i) => (
|
||||
<li key={item}>
|
||||
<Icon name={NO_ICONS[i]} size={20} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
|
||||
<p className="muted small">{busy ? t.submitting : t.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
+92
-67
@@ -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>
|
||||
<div className="panel">
|
||||
<Body texts={notes} />
|
||||
</div>
|
||||
<h1 className="h1 center">{head}</h1>
|
||||
{rest.length > 0 && (
|
||||
<div className="panel">
|
||||
<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,19 +702,12 @@ 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}>
|
||||
@@ -699,7 +724,7 @@ export function Flow() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell focus sos={step?.kind !== "crisis"}>
|
||||
<AppShell focus bare sos={step?.kind !== "crisis"}>
|
||||
{content}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,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 && (
|
||||
|
||||
+52
-46
@@ -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 className="entry__title">
|
||||
{t.emias.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="entry__title">
|
||||
{t.anonymous.title}
|
||||
<Icon name="chevronRight" />
|
||||
</span>
|
||||
<span className="entry__text">{t.anonymous.text}</span>
|
||||
<ul className="signin__perks">
|
||||
{t.emias.perks.map((p) => (
|
||||
<li key={p}>
|
||||
<Icon name="check" size={18} />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<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>
|
||||
</AppShell>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+274
-51
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user