diff --git a/app/api/patient_web.py b/app/api/patient_web.py index e69b6ab..847b920 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -29,7 +29,9 @@ from sqlalchemy import select from app.api import fixtures from app.core import clock from app.core.instrument_catalog import Catalog, catalog +from app.core.intake_questions import DIARY_KINDS from app.core.intake_runtime import intake_enabled, load_runtime +from app.core.interpretation import texts as results_texts from app.core.meds_reference import find_drug, indication_for, load_reference from app.core.safety import CrisisResource, crisis_reply from app.db import repo @@ -56,6 +58,15 @@ class EmiasCodeIn(BaseModel): code: str = Field(min_length=1, max_length=CODE_MAX_LEN) +class AnonymousIn(BaseModel): + model_config = ConfigDict(extra="forbid") + focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module + + +# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010). +_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"} + + class EmiasDemoIn(BaseModel): model_config = ConfigDict(extra="forbid") persona_token: str = Field(min_length=1, max_length=64) @@ -134,9 +145,16 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut: @router.post("/auth/anonymous", response_model=AuthOut) -async def auth_anonymous() -> AuthOut: - """Anonymous sign-in: a fresh random token; nothing about the person is stored anywhere.""" +async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut: + """Anonymous sign-in: a fresh random token; no identity record, no doctor. + + With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the + matching onboarding instead of going straight to the menu. Without it nothing is stored at all. + """ token = secrets.token_hex(TOKEN_LEN // 2) + if body is not None and body.focus is not None: + async with get_clinical_session() as s: + await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]]) log.info("web auth: anonymous tok=%s", token[:6]) return AuthOut(token=token, mode="anonymous") @@ -170,7 +188,8 @@ class Questionnaires(BaseModel): enabled: bool intake: Literal["none", "active", "paused", "complete", "review"] - diary_done: bool + diary_done: bool # both parts of today's diary + diary_next: Literal["morning", "evening"] | None = None # the part still open today class TodayOut(BaseModel): @@ -240,7 +259,9 @@ async def my_today(token: str) -> TodayOut: week = [week_start + timedelta(days=i) for i in range(7)] modules = list(profile.modules or []) if profile else [] diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date() - diary_done = any(r.kind == "diary" and r.day == diary_day and r.status == "complete" for r in records) + done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"} + open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts] + diary_done = not open_parts def item(p: Prescription) -> PrescriptionItem: ref = find_drug(p.drug) @@ -258,7 +279,7 @@ async def my_today(token: str) -> TodayOut: return TodayOut( today=today, now=now.strftime("%H:%M"), - has_doctor=profile is not None, + has_doctor=profile is not None and bool(profile.doctor_id), checkin_done=(checkin is not None and checkin.mood is not None) or diary_done, next_visit=profile.next_visit if profile else None, doses=today_doses, @@ -268,13 +289,94 @@ async def my_today(token: str) -> TodayOut: if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today) ], questionnaires=Questionnaires( - enabled=intake_enabled() and profile is not None, + # Stage 1 needs no profile: the screening creates it (anonymous people have none before it). + enabled=intake_enabled(), intake=_intake_state(records), diary_done=diary_done, + diary_next=open_parts[0] if open_parts else None, ), ) +class ResultFinding(BaseModel): + code: str + title: str + + +class Conclusion(BaseModel): + """Preliminary conclusion of stage 1 — a screening result, not a diagnosis.""" + + day: date + title: str + lines: list[str] + findings: list[ResultFinding] + modules: list[str] + disclaimer: str + + +class HistoryItem(BaseModel): + id: str + kind: str + kind_title: str + status: str + status_title: str + day: date + started_at: datetime + completed_at: datetime | None + title: str | None # interpretation shown to the patient when the form was finished + lines: list[str] + + +class ResultsOut(BaseModel): + conclusion: Conclusion | None + history: list[HistoryItem] + + +@router.get("/me/{token}/results", response_model=ResultsOut) +async def my_results(token: str) -> ResultsOut: + """The patient's own questionnaire log: every administration with its date, status and the interpretation that + was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers.""" + async with get_clinical_session() as s: + records = list( + ( + await s.scalars( + select(QuestionnaireRecord) + .where(QuestionnaireRecord.token == token) + .order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id) + ) + ).all() + ) + t = results_texts() + intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None) + conclusion = None + if intake and intake.details.get("interpretation"): + i = intake.details["interpretation"] + conclusion = Conclusion( + day=intake.day, + title=i["title"], + lines=i["lines"], + findings=[ResultFinding(**f) for f in i.get("findings", [])], + modules=i.get("modules", []), + disclaimer=i.get("disclaimer", t["disclaimer"]), + ) + history = [ + HistoryItem( + id=r.id, + kind=r.kind, + kind_title=t["kinds"].get(r.kind, r.kind), + status=r.status, + status_title=t["status"].get(r.status, r.status), + day=r.day, + started_at=r.started_at, + completed_at=r.completed_at, + title=(r.details.get("interpretation") or {}).get("title"), + lines=(r.details.get("interpretation") or {}).get("lines", []), + ) + for r in records + ] + return ResultsOut(conclusion=conclusion, history=history) + + @router.get("/crisis/resources", response_model=list[CrisisResource]) async def crisis_resources() -> list[CrisisResource]: """Help lines for the always-visible «help now» button — the same list as the fixed crisis script.""" diff --git a/app/core/instrument_catalog.py b/app/core/instrument_catalog.py index 05c965c..7b0a3b5 100644 --- a/app/core/instrument_catalog.py +++ b/app/core/instrument_catalog.py @@ -9,12 +9,11 @@ the versioned sources in config/. from functools import lru_cache from typing import Any, Literal -import yaml from pydantic import BaseModel from app.config import get_settings from app.core.cssrs_content import load_cssrs_russian -from app.core.intake_questions import content, texts +from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts from app.core.scales import load_scale Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"] @@ -113,13 +112,15 @@ def catalog() -> Catalog: add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"]) add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"]) - diary = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8")) - diary_items = [item for key in ("core", "ptsd", "depression", "suicide") for item in diary.get(key, [])] + diary = diary_config() + core = [item for part in DIARY_PARTS for item in diary[part]] + depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]] + ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]] add( FormInfo(id="diary", title="Дневник самочувствия", section="diary"), - [i["id"] for i in diary_items], - optional={i["id"] for i in diary_items if i.get("optional")}, - numeric={i["id"] for i in diary_items if i.get("numeric")}, + [i["id"] for i in core] + depression + ptsd, + optional={i["id"] for i in core if i.get("optional")}, + numeric={i["id"] for i in core if i.get("numeric")}, ) # Scales of the onboarding / scheduled sessions (`scale::...` buttons): the form header only. diff --git a/app/core/intake_questions.py b/app/core/intake_questions.py index 8c598a2..b332574 100644 --- a/app/core/intake_questions.py +++ b/app/core/intake_questions.py @@ -18,6 +18,7 @@ class Question: period: str = "context" numeric: bool = False optional: bool = False + maximum: float = 24 def content() -> dict: @@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]: def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None: - c = score_cssrs_recent(cssrs_answers(answers)) - if c.missing: - form = load_cssrs_russian() - item = next(i for i in form.items if i.id == c.missing[0]) - period = texts()["periods"][item.recall_period] - # Guidance is interviewer-facing; retained in the source and record version. - return Question( - "cssrs_" + item.id, - period + "\n\n" + item.text, - tuple((o.value, o.label) for o in form.options), - item.recall_period, - ) + """Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, + then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. + Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py), + so a positive answer interrupts the questionnaire wherever it comes.""" for item in ("phq9_1", "phq9_2"): if item not in answers: return scale_question("phq9", item) @@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio for item in load_scale("rus_audit_s").item_ids: if item not in answers: return scale_question("rus_audit_s", item) + c = score_cssrs_recent(cssrs_answers(answers)) + if c.missing: + form = load_cssrs_russian() + item = next(i for i in form.items if i.id == c.missing[0]) + period = texts()["periods"][item.recall_period] + # Guidance is interviewer-facing; retained in the source and record version. + return Question( + "cssrs_" + item.id, + period + "\n\n" + item.text, + tuple((o.value, o.label) for o in form.options), + item.recall_period, + ) if sex is None and "sex" not in answers: return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать"))) for key in ("tbi", "psychotropics"): @@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio return None -def diary_questions( - modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None -) -> tuple[Question, ...]: - config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text()) - selected = ["core"] - detailed = [name for name in ("veteran", "depression") if name in modules] - if len(detailed) == 2: - detailed = [detailed[(day_index if day_index is not None else weekday) % 2]] - if "veteran" in detailed: - selected.append("ptsd") - if "depression" in detailed: - selected.append("depression") - if suicide_monitoring or "depression" in modules: - selected.append("suicide") - questions = [] - for section in selected: - for item in config[section]: - if item.get("weekly") and weekday != 0: - continue - questions.append( - Question( - item["id"], - item["text"], - tuple((o["value"], o["label"]) for o in item.get("options", [])), - "past_week" if item.get("weekly") else "past_day", - item.get("numeric", False), - item.get("optional", False), +def cssrs_since_contact_question(answers: dict) -> Question | None: + """Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact». + + The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the + items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact + is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies. + """ + c = score_cssrs_recent(cssrs_answers(answers)) + missing = [k for k in c.missing if k != "q6_recent"] + if not missing: + return None + form = load_cssrs_russian() + item = next(i for i in form.items if i.id == missing[0]) + return Question( + "cssrs_" + item.id, + texts()["periods"]["since_last_contact"] + "\n\n" + item.text, + tuple((o.value, o.label) for o in form.options), + "since_last_contact", + ) + + +DIARY_PARTS = ("morning", "evening") +DIARY_KINDS = ("diary_morning", "diary_evening") + + +def diary_config() -> dict: + return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8")) + + +def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]: + """Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile. + + PTSD («veteran») and depression run side by side when both are present. The depression module asks three + PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`) + so diary answers never mix with the full scales. + """ + if part not in DIARY_PARTS: + raise ValueError("unknown diary part") + config = diary_config() + questions = [ + Question( + item["id"], + item["text"], + tuple((o["value"], o["label"]) for o in item.get("options", [])), + "past_day", + item.get("numeric", False), + item.get("optional", False), + item.get("max", 24), + ) + for item in config[part] + ] + if part == "evening": + forms = content()["forms"] + if "depression" in modules: + module = config["depression"] + items = forms[module["form"]]["items"] + per_day = module["items_per_day"] + start = (day_index % (len(items) // per_day)) * per_day + options = tuple((o["value"], o["label"]) for o in module["options"]) + for item in items[start : start + per_day]: + number = item["id"].rsplit("_", 1)[1] + questions.append( + Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today") + ) + if "veteran" in modules: + module = config["ptsd"] + form = forms[module["form"]] + by_id = {i["id"]: i["text"] for i in form["items"]} + options = tuple((o["value"], o["label"]) for o in form["options"]) + for item_id in module["items"]: + number = item_id.rsplit("_", 1)[1] + questions.append( + Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today") ) - ) - questions.sort(key=lambda q: q.id != "death_thoughts") return tuple(questions) diff --git a/app/core/intake_runtime.py b/app/core/intake_runtime.py index e7b82e3..6ee7ada 100644 --- a/app/core/intake_runtime.py +++ b/app/core/intake_runtime.py @@ -1,6 +1,5 @@ """Versioned release gate and deterministic follow-up planning.""" -import os from calendar import monthrange from datetime import date, timedelta from pathlib import Path @@ -11,7 +10,7 @@ import yaml from pydantic import BaseModel, ConfigDict, Field, model_validator from app.config import get_settings -from app.core.questionnaire import Approval, load_policy +from app.core.questionnaire import Approval, demo_mode, load_policy class RuntimePolicy(BaseModel): @@ -22,13 +21,20 @@ class RuntimePolicy(BaseModel): content_review: Approval | None = None contact_review: Approval | None = None timezone: str - daily_hour: int = Field(ge=0, le=23) - mood_baseline_days: int = Field(ge=2) - mood_bad_days: int = Field(ge=1) - mood_drop: int = Field(ge=1, le=10) + morning_hour: int = Field(ge=0, le=23) + evening_hour: int = Field(ge=0, le=23) + norm_days: int = Field(ge=2) + week_days: int = Field(ge=2) + min_norm_values: int = Field(ge=1) + min_week_values: int = Field(ge=1) + mood_drop: float = Field(gt=0, le=10) + depression_item_rise: float = Field(gt=0, le=3) + ptsd_sum_rise: float = Field(gt=0, le=16) sleep_bad_days: int = Field(ge=1) sleep_hours_below: float = Field(gt=0, le=24) - nightmares_at: int = Field(ge=0, le=4) + alcohol_heavy_portions: float = Field(gt=0) + alcohol_heavy_days: int = Field(ge=1) + adherence_below: float = Field(gt=0, le=1) missed_medication_days: int = Field(ge=1) high_risk_silence_days: int = Field(ge=1) medication_days: tuple[int, ...] @@ -45,6 +51,8 @@ class RuntimePolicy(BaseModel): set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values() ): raise ValueError("all route and scale intervals must be defined") + if self.morning_hour >= self.evening_hour: + raise ValueError("the morning diary must come before the evening one") if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days: raise ValueError("medication days must be nonempty and strictly increasing") if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()): @@ -71,7 +79,7 @@ def demo_intake() -> bool: It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off until the approvals are recorded there. """ - return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock + return demo_mode() def intake_enabled() -> bool: @@ -125,39 +133,73 @@ def due_scales( return tuple(sorted(due)) -def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]: - """Consecutive calendar dates; missing data never become zero or missed doses. +def _mean(values: list[float]) -> float: + return sum(values) / len(values) - Baseline is the seven dates BEFORE the three-day deterioration window; - every baseline value is required. Nightmares OR short sleep must hold on - each of three nights. These candidate definitions require signed review. + +def diary_flags( + history: dict[date, dict], + today: date, + policy: RuntimePolicy | None = None, + *, + medication_active: bool = False, +) -> tuple[str, ...]: + """Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days. + + `history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of + observation; trends are checked only after it. Missing data never become zero or missed doses. """ p = policy or load_runtime() - flags = [] + flags: list[str] = [] current = history.get(today, {}) - if current.get("death_thoughts") == 1: - flags.append("suicide_assessment_now") - recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)] - baseline = [ - history.get(today - timedelta(days=i), {}).get("mood") - for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days) - ] - if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent): - mean = sum(baseline) / len(baseline) - if all(mean - x["mood"] >= p.mood_drop for x in recent): - flags.append("phq9_due_mood") + start = min(history) if history else today + norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days] + week_start = today - timedelta(days=p.week_days - 1) + week = [history[d] for d in sorted(history) if week_start <= d <= today] + trend = (week_start - start).days >= p.norm_days + + def values(rows: list[dict], key) -> list[float]: + return [v for r in rows if (v := key(r)) is not None] + + def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool: + before, now = values(norm, key), values(week, key) + if not trend or len(before) < min_norm or len(now) < min_week: + return False + change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before) + return change >= by + + if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True): + flags.append("phq9_due_mood") + # Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item. + for item in range(1, 10): + if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2): + flags.append("phq9_due_depression") + break + # PTSD module: only the sum of the four items is interpreted, never single clusters. + ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18") + + def ptsd_sum(r): + return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None + + if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values): + flags.append("pcl5_due_ptsd") nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)] if all( - (r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at) - or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) + (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0 for r in nights ): flags.append("sleep_review") + heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions] + if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions: + flags.append("alcohol_on_medication") + if len(heavy) >= p.alcohol_heavy_days: + flags.append("rus_audit_due_alcohol") doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)] - if all(r.get("medication") == "none" for r in doses): + if all(r.get("medication") == "no" for r in doses): flags.append("medication_contact") - if current.get("side_effects") == 1: + taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")] + if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below: + flags.append("adherence_review") + if current.get("medication_reason") == "side_effects": flags.append("side_effects_review") - if current.get("medication") == "partial": - flags.append("partial_medication_review") return tuple(flags) diff --git a/app/core/interpretation.py b/app/core/interpretation.py new file mode 100644 index 0000000..aa46d65 --- /dev/null +++ b/app/core/interpretation.py @@ -0,0 +1,165 @@ +"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml. + +Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2. +Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags. +Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3). +""" + +from typing import Any + +import yaml + +from app.config import get_settings +from app.core.intake_questions import content +from app.core.questionnaire import IntakeDecision, IntakePolicy + +# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile). +FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"} +# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022). +PHQ9_REMISSION_BELOW = 5 +PHQ9_IMPROVED_BY = 5 +PCL5_RESPONSE_BY = 10 +PCL5_IMPROVED_BELOW = 28 + + +def texts() -> dict[str, Any]: + return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")) + + +def render(interpretation: dict[str, Any]) -> str: + """Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any).""" + parts = [interpretation["title"], *interpretation.get("lines", [])] + if interpretation.get("disclaimer"): + parts.append(interpretation["disclaimer"]) + return "\n\n".join(p for p in parts if p) + + +def _alcohol_zone(totals: dict) -> int | None: + full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {} + return full.get("zone") if full.get("complete") else short.get("zone") + + +def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]: + """Screening findings of stage 1, in the order they are shown.""" + found = [] + phq = totals.get("phq9") or {} + if phq.get("complete"): + if phq["total"] >= policy.phq9_specialist_at: + found.append("depression_suspected") + elif phq["total"] >= policy.phq9_psychologist_at: + found.append("depression_mild") + pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {} + if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at: + found.append("ptsd_suspected") + elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at: + found.append("ptsd_signs") + zone = _alcohol_zone(totals) + if zone == 4: + found.append("alcohol_high") + elif zone in (2, 3): + found.append("alcohol_risky") + if (totals.get("cssrs_recent") or {}).get("risk") == "low": + found.append("suicide_low") + if answers.get("tbi") == 1: + found.append("tbi") + return found + + +def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict: + t = texts() + found = intake_findings(totals, answers, policy) + modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE] + head = t["intake"]["attention" if found else "ok"] + lines = [head["text"]] + lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found] + if decision.primary_route in t["routes"]: + lines.append(t["routes"][decision.primary_route]) + names = ", ".join(t["modules"]["names"][m] for m in modules) + lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else "")) + return { + "kind": "intake", + "title": head["title"], + "lines": lines, + "codes": found or ["ok"], + "findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found], + "route": decision.primary_route, + "modules": modules, + "disclaimer": t["disclaimer"], + } + + +def _phq9_level(total: int) -> str: + band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"]) + return band["code"] + + +def scale_interpretation( + kind: str, + totals: dict, + *, + baseline: int | None, + previous: int | None, + policy: IntakePolicy, + retriage_delta: int, +) -> dict: + """A scheduled full scale: level, then the change against the start of observation and the last measurement.""" + t = texts()["scales"] + if kind == "rus_audit_s": + zone = _alcohol_zone(totals) + result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"] + level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"] + line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level) + return { + "kind": kind, + "title": t[kind]["title"], + "lines": [line], + "codes": [f"zone_{zone}" if zone else "unknown"], + } + total = totals[kind]["total"] + pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below" + code = _phq9_level(total) if kind == "phq9" else pcl5_level + lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])] + change = None + if previous is not None and total - previous >= retriage_delta: + change = "worse" + elif baseline is not None: + if kind == "phq9": + if total < PHQ9_REMISSION_BELOW: + change = "remission" + elif baseline > 0 and total <= baseline / 2: + change = "response" + elif baseline - total >= PHQ9_IMPROVED_BY: + change = "improved" + else: + change = "stable" + else: + if baseline - total >= PCL5_RESPONSE_BY: + change = "response" + elif total < PCL5_IMPROVED_BELOW <= baseline: + change = "improved" + else: + change = "stable" + if change: + lines.append(t["change"][change]) + return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]} + + +def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict: + t = texts()["diary"] + shown = [f for f in flags if f in t["flags"]] + return { + "kind": f"diary_{part}", + "title": t["saved"].format(part=t["parts"][part]), + "lines": [t["flags"][f] for f in shown], + "codes": list(flags), + } + + +def cssrs_interpretation(risk: str | None) -> dict: + t = texts()["cssrs"] + return { + "kind": "cssrs_since_contact", + "title": t["low" if risk == "low" else "none"], + "lines": [], + "codes": [risk or "none"], + } diff --git a/app/core/questionnaire.py b/app/core/questionnaire.py index b148bd6..09a3065 100644 --- a/app/core/questionnaire.py +++ b/app/core/questionnaire.py @@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati from __future__ import annotations +import os from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -69,8 +70,17 @@ class IntakePolicy(BaseModel): return self +def demo_mode() -> bool: + """Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data. + + It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the + real policy and the release gate in config/intake_runtime.yaml are never touched. + """ + return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock + + def load_policy(path: Path | None = None) -> IntakePolicy: - path = path or get_settings().config_dir / "questionnaire.yaml" + path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml") return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) diff --git a/app/dialog/flows/core.py b/app/dialog/flows/core.py index 2d3ec47..21f585f 100644 --- a/app/dialog/flows/core.py +++ b/app/dialog/flows/core.py @@ -69,7 +69,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str: modules = list(profile.modules) if profile is not None and profile.modules else [] from app.core.intake_runtime import intake_enabled - if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")): + # Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous + # person has no profile at all before it. + if after is None and intake_enabled(): return "intake.start" if after in modules: modules = modules[modules.index(after) + 1 :] diff --git a/app/dialog/flows/intake.py b/app/dialog/flows/intake.py index 80c801b..22b2905 100644 --- a/app/dialog/flows/intake.py +++ b/app/dialog/flows/intake.py @@ -4,9 +4,13 @@ import math from dataclasses import asdict, replace from zoneinfo import ZoneInfo +from app.core import interpretation from app.core.intake_questions import ( + DIARY_KINDS, + Question, content, cssrs_answers, + cssrs_since_contact_question, diary_questions, group_answers, next_intake, @@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent from app.core.scales import load_scale from app.core.types import AlertLevel +from app.db import repo from app.db.intake import QuestionnaireRecord, QuestionnaireTask from app.dialog.contracts import Button, Event, Reply from app.dialog.crisis import crisis_open, open_crisis @@ -28,21 +33,23 @@ def allowed(ctx): return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled() +def side_effects_question(): + """FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3).""" + return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week") + + def next_question(row): a = row.answers if row.kind == "intake": return next_intake(a, load_policy(), row.details.get("sex")) - if row.kind == "diary": - for q in diary_questions( - row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday() - ): + if row.kind == "cssrs_since_contact": + return cssrs_since_contact_question(a) + if row.kind in DIARY_KINDS: + part = row.kind.removeprefix("diary_") + for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)): if q.id in a: continue - if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"): - continue - if q.id == "side_effects" and a.get("medication") == "not_prescribed": - continue - if q.id == "thought_intensity" and a.get("death_thoughts") != 1: + if q.id == "medication_reason" and a.get("medication") != "no": continue return q return None @@ -63,11 +70,7 @@ def next_question(row): q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week") return q if row.details.get("medication_active") and "side_effects" not in a: - return next( - q - for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday()) - if q.id == "side_effects" - ) + return side_effects_question() return None @@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None): if profile: profile.enhanced_monitoring = True flags = list(service.decision(row).flags) if row else [] - if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0): + if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0): flags.append("cssrs_since_contact_required") if row: await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags}) @@ -132,7 +135,8 @@ async def advance(ctx, row): a = row.answers c = score_cssrs_recent(cssrs_answers(a)) policy = load_policy() - if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high": + # PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule). + if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high": return await urgent(ctx, row, code="suicide_signal") if c.positive and policy.cssrs_positive_policy == "stop_any": return await urgent(ctx, row, code="cssrs_positive") @@ -153,9 +157,6 @@ async def advance(ctx, row): ctx.now, params={"record_id": row.id}, ) - if row.kind == "cssrs_since_contact": - # Recent and Since Last Contact are different instruments. No silent substitution. - return await manual_review(ctx, row, "cssrs_since_contact_required") try: q = next_question(row) # Validate preserved short answers against a newly answered full-form item 1. @@ -187,6 +188,8 @@ async def advance(ctx, row): extra = {"decision": asdict(result), "totals": service.summary(row)} if a.get("recent_trauma") == 1: extra["recent_trauma"] = True + extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy) + await apply_modules(ctx, extra["interpretation"]["modules"]) if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"): await service.alert( ctx.session, @@ -196,23 +199,29 @@ async def advance(ctx, row): ctx.now, params={"record_id": row.id, **extra}, ) - elif row.kind == "diary": - history = { - r.day: r.answers - for r in await service.records(ctx.session, ctx.token) - if r.kind == "diary" and r.status == "complete" - } - history[row.day] = a - flags = diary_flags(history, row.day) + elif row.kind == "cssrs_since_contact": + extra["totals"] = {"cssrs_recent": asdict(c)} + extra["interpretation"] = interpretation.cssrs_interpretation(c.risk) + elif row.kind in DIARY_KINDS: + # Morning and evening answers of one date form one diary day. + history: dict = {} + for r in await service.records(ctx.session, ctx.token): + if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id: + history.setdefault(r.day, {}).update(r.answers) + history.setdefault(row.day, {}).update(a) + flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False)) extra["flags"] = flags for flag in flags: await service.alert( ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id} ) - if flag == "phq9_due_mood": - await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now) + if flag in DIARY_SCALE_TASKS: + sid = DIARY_SCALE_TASKS[flag] + await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now) + extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags) else: extra["totals"] = service.summary(row) + runtime = load_runtime() if row.kind in ("phq9", "pcl5"): sid = row.kind total = extra["totals"][sid]["total"] @@ -223,27 +232,37 @@ async def advance(ctx, row): if total >= priority else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician") ) - prior = next( - ( - r - for r in reversed(await service.records(ctx.session, ctx.token)) - if r.id != row.id - and r.status == "complete" - and r.version == row.version - and r.details.get("totals", {}).get(sid, {}).get("complete") - and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False)) - ), - None, - ) + earlier = [ + r + for r in await service.records(ctx.session, ctx.token) + if r.id != row.id + and r.status == "complete" + and r.version == row.version + and r.details.get("totals", {}).get(sid, {}).get("complete") + and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False)) + ] + prior = earlier[-1] if earlier else None if prior: change = total - prior.details["totals"][sid]["total"] extra["delta"] = { "baseline_record": prior.id, "value": change, - "retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"), + "retriage": change >= getattr(runtime, sid + "_retriage_delta"), } + extra["interpretation"] = interpretation.scale_interpretation( + sid, + extra["totals"], + baseline=earlier[0].details["totals"][sid]["total"] if earlier else None, + previous=prior.details["totals"][sid]["total"] if prior else None, + policy=policy, + retriage_delta=getattr(runtime, sid + "_retriage_delta"), + ) if row.kind == "rus_audit_s" and not row.details.get("sex"): return await manual_review(ctx, row, "alcohol_sex_unknown") + if row.kind == "rus_audit_s": + extra["interpretation"] = interpretation.scale_interpretation( + "rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0 + ) if extra["totals"]["rus_audit"].get("zone") == 4: extra["parallel_routes"] = ["narcologist"] retriage = extra.get("delta", {}).get("retriage", False) @@ -257,13 +276,48 @@ async def advance(ctx, row): params={"record_id": row.id, **extra}, ) await service.finish(ctx, row, "complete", extra=extra) + shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"] + if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"): + # The risk questions open the evening diary; continue straight into it. + step = await start(ctx, "diary_evening") + step.replies.insert(0, Reply(text=shown, kind="info")) + return step from app.dialog.flows.core import menu_reply - replies = [menu_reply(ctx, texts()["done"])] - alcohol_total = extra.get("totals", {}).get("rus_audit", {}) - if alcohol_total.get("zone") in (2, 3): - replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info")) - return Step(replies=replies, next_state="idle", data={"questionnaire_record": None}) + return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None}) + + +# Diary flags that bring a full scale forward («Этап 2», p.4). +DIARY_SCALE_TASKS = { + "phq9_due_mood": "phq9", + "phq9_due_depression": "phq9", + "pcl5_due_ptsd": "pcl5", + "rus_audit_due_alcohol": "rus_audit_s", +} + + +async def apply_modules(ctx, modules): + """Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules + (diagnosis from ЕМИАС) are kept and only extended.""" + profile = await ctx.profile() + if profile is None: + await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules)) + return + current = list(profile.modules or []) + merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules) + await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged) + + +def local_day(ctx): + return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date() + + +async def done_today(ctx, kind): + day = local_day(ctx) + return any( + r.kind == kind and r.day == day and r.status == "complete" + for r in await service.records(ctx.session, ctx.token) + ) async def start(ctx, kind="intake", task=None): @@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None): if task: return Step() return Step(replies=[Reply(text=texts()["review"], kind="info")]) + # A manual start takes over the scheduled task of the same form, so the task does not stay outstanding. + task = task or await service.outstanding_task(ctx.session, ctx.token, kind) row = await service.create_record(ctx, kind, task=task) return await advance(ctx, row) @@ -316,14 +372,28 @@ async def checkin(ctx, event): existing = await service.records(ctx.session, ctx.token) if not any(r.kind == "intake" and r.status == "complete" for r in existing): return await start(ctx) - day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date() - if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing): - return Step(replies=[Reply(text=texts()["done"], kind="info")]) - risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now) - if risk_task.status in ("pending", "active"): - await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now) - return await start(ctx, "cssrs_since_contact", risk_task) - return await start(ctx, "diary") + return await next_diary(ctx) + + +async def next_diary(ctx): + """The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS + «since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk).""" + day = local_day(ctx) + done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"} + for kind in DIARY_KINDS: + if kind in done: + continue + profile = await ctx.profile() + if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done: + risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now) + return await start(ctx, "cssrs_since_contact", risk_task) + return await start(ctx, kind) + return Step(replies=[Reply(text=texts()["diary_done"], kind="info")]) + + +@trigger("button:diary:next") +async def diary_button(ctx, event): + return await next_diary(ctx) @state("intake") @@ -365,12 +435,16 @@ async def collect(ctx, event: Event): elif event.type == "text" and q.numeric: try: value = float(str(ctx.text).replace(",", ".")) - valid = math.isfinite(value) and 0 <= value <= 24 + valid = math.isfinite(value) and 0 <= value <= q.maximum except ValueError: pass elif event.type == "text" and q.optional: value, valid = str(ctx.text)[:2000], True if not valid: - return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)]) + hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric") + return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)]) service.save_answer(row, q, value, ctx.now) + if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime": + # Behaviour since the last contact is recent by definition (see cssrs_since_contact_question). + service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True) return await advance(ctx, row) diff --git a/app/dialog/flows/meds.py b/app/dialog/flows/meds.py index d079160..fecc1cb 100644 --- a/app/dialog/flows/meds.py +++ b/app/dialog/flows/meds.py @@ -46,6 +46,8 @@ from app.llm.med_qa import fallback_text, med_qa QUESTION_STATE = "meds.question" SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text" +# A client sends this button when the person submits a free-text step without writing anything. +NO_COMMENT = "text:skip" SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other") SKIP_REASONS = ("forgot", "side_effects", "quit", "other") @@ -378,7 +380,8 @@ def _side_effect_code(code: str) -> Any: async def handler(ctx: Ctx, event: Event, ref: str) -> Step: if code == "other": return Step( - replies=[Reply(text=load_meds_texts().side_effects.describe)], + # kind="question": the engine waits for free text here (clients show an input, optional). + replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")], next_state=SIDE_EFFECT_TEXT_STATE, data={"med_se_ref": ref}, ) @@ -393,12 +396,15 @@ for _code in SIDE_EFFECT_CODES: @state(SIDE_EFFECT_TEXT_STATE) async def _side_effect_text(ctx: Ctx, event: Event) -> Step: + ref = ctx.data.get("med_se_ref") + if event.type == "button" and event.button == NO_COMMENT: + # The description is optional: «other» side effect without words. + return await _after_side_effects(ctx, ["other"], str(ref) if ref else None) if event.type != "text" or ctx.text is None: return await leave_to_idle(ctx, event, {"med_se_ref": None}) facts = await ctx.gateway.extract_checkin(ctx.text) # Store only normalised codes, never the description itself (it stays in the scrubbed message log). codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"] - ref = ctx.data.get("med_se_ref") return await _after_side_effects(ctx, codes, str(ref) if ref else None) @@ -428,7 +434,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step: t = load_meds_texts().question if await current_prescription(ctx) is None: return Step(replies=[menu_reply(ctx, t.no_prescription)]) - return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE) + return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE) async def answer_question(ctx: Ctx, rx: Rx | None) -> str: diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index 9a72211..e6f50e8 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -15,7 +15,9 @@ from sqlalchemy import select, text from sqlalchemy.exc import SQLAlchemyError from app.core import clock +from app.core.intake_questions import DIARY_KINDS from app.core.intake_runtime import due_scales, intake_enabled, load_runtime +from app.core.questionnaire import load_policy from app.db import repo from app.db.clinical import PatientProfile from app.db.intake import QuestionnaireTask @@ -45,16 +47,18 @@ async def plan(): initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) if initial is None: continue - last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day]) + diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"] + last_activity = max([*diaries, initial.day]) high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis")) if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days: await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now) if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"): continue - if local.hour >= p.daily_hour and not any( - r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows - ): - await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now) + done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"} + # Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2). + for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)): + if local.hour >= hour and kind not in done: + await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now) last = {} for row in rows: if row.status != "complete": @@ -82,12 +86,21 @@ async def plan(): after_crisis=crisis_date, policy=p, ) - if scales or local.hour >= p.daily_hour: + # Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring. + if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done: await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now) + totals = initial.details.get("totals", {}) + modules = set(profile.modules or []) + policy = load_policy() + # PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3). + wanted = { + "phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")), + "pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at, + "rus_audit_s": True, + } for sid in scales: - if sid == "pcl5" and initial.answers.get("trauma") != 1: - continue - await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now) + if wanted[sid]: + await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now) async def deliver(): diff --git a/app/services/questionnaire.py b/app/services/questionnaire.py index 364a637..76a62f6 100644 --- a/app/services/questionnaire.py +++ b/app/services/questionnaire.py @@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None): sex = profile.sex if profile else None if sex is None and prior: sex = prior.details.get("sex") + day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date() row = QuestionnaireRecord( id=uuid4().hex, token=ctx.token, @@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None): version=content()["version"], policy_version=load_policy().version, started_at=ctx.now, - day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(), + day=day, status="active", answers={}, details={ @@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None): "weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")), "answer_metadata": {}, "task_id": task.id if task else None, + # Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day). + "day_index": (day - prior.day).days if prior else 0, }, ) ctx.session.add(row) @@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None): return row -def save_answer(row, question, value, now): +def save_answer(row, question, value, now, *, derived=False): """Anchor recall windows to administration, never to the day of a resumed answer.""" day = row.day starts = { @@ -105,8 +108,8 @@ def save_answer(row, question, value, now): "period": question.period, "period_start": starts[question.period].isoformat() if question.period in starts else None, "period_end": day.isoformat(), - "source": "patient", - "derived": False, + "source": "derived" if derived else "patient", + "derived": derived, }, }, } @@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now): return row +async def outstanding_task(session, token, kind): + return await session.scalar( + select(QuestionnaireTask).where( + QuestionnaireTask.token == token, + QuestionnaireTask.kind == kind, + QuestionnaireTask.status.in_(("pending", "active")), + ) + ) + + async def lock_patient(session, token): # PostgreSQL serializes callbacks from different workers. SQLite tests additionally # use the engine's in-process lock. Consent and answers share this transaction. diff --git a/config/instruments/diary.yaml b/config/instruments/diary.yaml index 5c0d73c..e69e6b6 100644 --- a/config/instruments/diary.yaml +++ b/config/instruments/diary.yaml @@ -1,189 +1,84 @@ -version: diary-v2-2026-10-03 -core: -- id: mood - text: 'Какое у Вас сегодня настроение? +# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3. +# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds +# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only. +# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran». +version: diary-v3-2026-10-04 - 0 — самое плохое, 10 — самое хорошее.' - options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' - - value: 5 - label: '5' - - value: 6 - label: '6' - - value: 7 - label: '7' - - value: 8 - label: '8' - - value: 9 - label: '9' - - value: 10 - label: '10' +# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00. +morning: - id: sleep_hours - text: Сколько часов Вы спали прошлой ночью? + source: Consensus Sleep Diary + text: Сколько часов Вы спали этой ночью? numeric: true -- id: rested - text: 'Насколько Вы выспались? - - 0 — совсем не выспались, 4 — полностью выспались.' + max: 24 +- id: sleep_quality + source: Consensus Sleep Diary + text: Как Вы оцениваете качество своего сна? options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' + - {value: 0, label: Очень плохо} + - {value: 1, label: Плохо} + - {value: 2, label: Удовлетворительно} + - {value: 3, label: Хорошо} + - {value: 4, label: Очень хорошо} +- id: alcohol_portions + source: принцип Timeline Followback; порция по RUS-AUDIT + text: |- + Сколько стандартных порций алкоголя Вы выпили вчера? + + Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0. + numeric: true + max: 50 +evening: +- id: mood + source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9) + text: |- + Как Вы оцениваете своё настроение сегодня? + + 0 — самое плохое, 10 — самое хорошее. + options: + - {value: 0, label: '0'} + - {value: 1, label: '1'} + - {value: 2, label: '2'} + - {value: 3, label: '3'} + - {value: 4, label: '4'} + - {value: 5, label: '5'} + - {value: 6, label: '6'} + - {value: 7, label: '7'} + - {value: 8, label: '8'} + - {value: 9, label: '9'} + - {value: 10, label: '10'} - id: medication - text: Приняли ли Вы сегодня лекарства по схеме? + source: КР ПТСР — дневник приёма лекарств + text: Приняли ли Вы сегодня лекарства, назначенные врачом? options: - - value: full - label: По схеме - - value: partial - label: Частично - - value: none - label: Нет - - value: not_prescribed - label: Не назначены + - {value: 'yes', label: Да} + - {value: 'no', label: Нет} + - {value: not_prescribed, label: Лекарства не назначены} - id: medication_reason - text: Почему не приняли лекарства по схеме? + source: КР ПТСР — дневник приёма лекарств + text: Почему не приняли? options: - - value: forgot - label: Забыл(а) - - value: side_effects - label: Побочные эффекты - - value: declined - label: Не хочу - - value: ran_out - label: Закончились - - value: other - label: Другая причина -- id: side_effects - text: Беспокоили ли Вас сегодня побочные эффекты лекарств? - options: &id001 - - value: 1 - label: Да - - value: 0 - label: Нет -- id: substances - text: Употребляли ли Вы сегодня алкоголь или другие вещества? - options: *id001 -- id: comment - text: Что хотите передать врачу? - optional: true -ptsd: -- id: intrusions - text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?' - options: &id002 - - value: 0 - label: Совсем нет - - value: 1 - label: Немного - - value: 2 - label: Умеренно - - value: 3 - label: Сильно - - value: 4 - label: Крайне сильно -- id: nightmares - text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?' - options: *id002 -- id: avoidance - text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?' - options: *id002 -- id: hypervigilance - text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?' - options: *id002 -- id: irritability - text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?' - options: *id002 + - {value: forgot, label: Забыл(а)} + - {value: side_effects, label: Побочные эффекты} + - {value: declined, label: Решил(а) не принимать} + - {value: ran_out, label: Закончились} + +# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer +# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9. +# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule). depression: -- id: interest - text: 'Насколько Вам сегодня было интересно и приятно что-то делать? - - 0 — совсем нет, 4 — крайне сильно.' - options: *id002 -- id: depressed - text: Насколько Вы сегодня чувствовали подавленность или безнадёжность? - options: *id002 -- id: energy - text: 'Насколько у Вас сегодня были силы и энергия? - - 0 — совсем нет, 4 — крайне сильно.' - options: *id002 -- id: planned_activity - text: Сделали ли Вы сегодня хотя бы одно запланированное дело? - options: *id001 -suicide: -- id: death_thoughts - text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить? - options: *id001 -- id: thoughts_intensity - text: 'Если да — насколько сильными они были? - - 0 — совсем слабые, 10 — максимально сильные.' + form: phq9 + items_per_day: 3 + question: 'Насколько сегодня Вас беспокоило:' options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' - - value: 5 - label: '5' - - value: 6 - label: '6' - - value: 7 - label: '7' - - value: 8 - label: '8' - - value: 9 - label: '9' - - value: 10 - label: '10' -- id: hope - text: 'Насколько Вы верите, что станет лучше? + - {value: 0, label: Совсем нет} + - {value: 1, label: Немного} + - {value: 2, label: Умеренно} + - {value: 3, label: Сильно} - 0 — совсем не верю, 10 — полностью верю.' - options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' - - value: 5 - label: '5' - - value: 6 - label: '6' - - value: 7 - label: '7' - - value: 8 - label: '8' - - value: 9 - label: '9' - - value: 10 - label: '10' -- id: burden - text: Чувствовали ли Вы себя обузой для близких? - options: *id001 - weekly: true +# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the +# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»). +ptsd: + form: pcl5 + items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18] + question: 'Насколько сильно сегодня Вас беспокоили:' diff --git a/config/intake_runtime.yaml b/config/intake_runtime.yaml index 0d187f4..2a13428 100644 --- a/config/intake_runtime.yaml +++ b/config/intake_runtime.yaml @@ -1,17 +1,27 @@ -version: intake-v2-2026-10-03 +version: intake-v3-2026-10-04 enabled: false approval: null # Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness. content_review: null contact_review: null timezone: Europe/Moscow -daily_hour: 19 -mood_baseline_days: 7 -mood_bad_days: 3 -mood_drop: 3 -sleep_bad_days: 3 +# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00. +morning_hour: 8 +evening_hour: 20 +# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`. +# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor. +norm_days: 14 +week_days: 7 +min_norm_values: 7 +min_week_values: 4 +mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9 +depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier +ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier +sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor sleep_hours_below: 4 -nightmares_at: 3 +alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3) +alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT +adherence_below: 0.8 # share of days with medication taken over a week missed_medication_days: 3 high_risk_silence_days: 2 medication_days: [7, 14, 28] diff --git a/config/questionnaire_demo.yaml b/config/questionnaire_demo.yaml new file mode 100644 index 0000000..77c4f50 --- /dev/null +++ b/config/questionnaire_demo.yaml @@ -0,0 +1,21 @@ +# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is +# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md). +# Values follow the MVP PDF so the demo can reach a result and stage 2: +# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table); +# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022). +version: mvp-v2-demo-2026-10-04 +approval: + reviewer: "DEMO — синтетические данные, не клиническое согласование" + decision_reference: "INTAKE_DEMO" + approved_at: "2026-10-04T00:00:00+03:00" + policy_version: mvp-v2-demo-2026-10-04 +cssrs_positive_policy: graded +rus_audit_s_expand_from_zone: 2 +phq2_expand_at: 2 +tsq_expand_at: 6 +phq9_psychologist_at: 5 +phq9_specialist_at: 10 +phq9_priority_at: 20 +pcl5_specialist_at: 31 +pcl5_priority_at: 50 +pcl5_behavior_flag_at: 3 diff --git a/config/texts/intake.yaml b/config/texts/intake.yaml index 0e88489..d33f1c7 100644 --- a/config/texts/intake.yaml +++ b/config/texts/intake.yaml @@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн tbi: "Были ли у Вас контузия или черепно-мозговая травма?" psychotropics: "Принимаете ли Вы сейчас психотропные препараты?" missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже." +side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?" +numeric_portions: "Введите число стандартных порций, например 0 или 2." +diary_done: "Дневник на сегодня уже заполнен. Спасибо!" periods: + since_last_contact: "С момента Вашего последнего ответа на эти вопросы." past_month: "За последний месяц." lifetime: "За всю жизнь." past_3_months: "За последние три месяца." diff --git a/config/texts/results.yaml b/config/texts/results.yaml new file mode 100644 index 0000000..0165968 --- /dev/null +++ b/config/texts/results.yaml @@ -0,0 +1,120 @@ +# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are +# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для +# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result. +disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации." + +intake: + ok: + title: "Признаков депрессии и ПТСР не выявлено" + text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше." + attention: + title: "Есть признаки, которые стоит обсудить со специалистом" + text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше." + +findings: + depression_suspected: + title: "Предположение депрессии" + text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике." + depression_mild: + title: "Лёгкие симптомы депрессии" + text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить." + ptsd_suspected: + title: "Склонность к ПТСР" + text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике." + ptsd_signs: + title: "Отдельные признаки посттравматического стресса" + text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР." + alcohol_risky: + title: "Рискованное употребление алкоголя" + text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом." + alcohol_high: + title: "Высокий риск зависимости от алкоголя" + text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом." + suicide_low: + title: "Тяжёлые мысли" + text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия." + tbi: + title: "Контузия или ЧМТ" + text: "Рекомендуем консультацию невролога." + +routes: + urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра." + priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни." + psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту." + psychologist: "Рекомендуем консультацию психолога." + self_help: "Достаточно самопомощи и короткого ежедневного дневника." + +modules: + title: "Что дальше" + diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию." + names: + depression: "«Депрессия»" + veteran: "«ПТСР»" + with: ", плюс модуль {names}" + +scales: + phq9: + title: "Опросник PHQ-9 (депрессия)" + levels: + minimal: "минимальные симптомы" + mild: "лёгкие симптомы" + moderate: "умеренные симптомы" + moderately_severe: "умеренно тяжёлые симптомы" + severe: "тяжёлые симптомы" + pcl5: + title: "Опросник PCL-5 (ПТСР)" + levels: + below: "ниже порога вероятного ПТСР" + probable: "выше порога вероятного ПТСР" + rus_audit_s: + title: "Опросник RUS-AUDIT-S (алкоголь)" + levels: + zone_1: "относительно низкий риск" + zone_2: "опасное употребление" + zone_3: "пагубное употребление" + zone_4: "риск зависимости" + unknown: "нельзя оценить без указания пола" + score: "{title}: {total} — {level}." + change: + remission: "Это ремиссия по шкале — отличный результат." + response: "По сравнению с началом наблюдения — ответ на лечение." + improved: "По сравнению с началом наблюдения — клинически значимое улучшение." + stable: "По сравнению с началом наблюдения существенных изменений нет." + worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план." + +diary: + saved: "Спасибо, дневник за {part} сохранён." + parts: + morning: "утро" + evening: "вечер" + flags: + phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока." + phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока." + pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока." + sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке." + alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку." + rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе." + medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему." + adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом." + side_effects_review: "Вы отметили побочные эффекты — врач получит отметку." + +cssrs: + none: "Спасибо за ответы." + low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело." + +kinds: + intake: "Первичный опрос" + diary_morning: "Дневник — утро" + diary_evening: "Дневник — вечер" + cssrs_since_contact: "Вопросы о безопасности" + phq9: "PHQ-9 по расписанию" + pcl5: "PCL-5 по расписанию" + rus_audit_s: "Алкоголь по расписанию" + diary: "Дневник" +status: + complete: "пройден" + review_required: "остановлен для оценки специалиста" + interrupted: "прерван: открыта экстренная помощь" + active: "в процессе" + paused: "на паузе" + cancelled: "отменён" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b3a1da2..6ec03da 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3,6 +3,12 @@ > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. +## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату +**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста». +**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется. +**Причина:** пациенту нужен понятный итог и путь дальше, а не тупик; решения о риске и маршруте при этом остаются детерминированными (ADR-007). +**Последствия:** отступления от присланного PDF сохранены по `docs/clinical/README.md`: TSQ вместо PC-PTSD-5 (v2 PDF), RUS-AUDIT-S — пункты 3/9/10 (ВОЗ). Открыто: номера 4 пунктов короткой PCL-5 (Price et al., 2016), официальная форма C-SSRS Since Last Contact (сейчас — тексты Recent с периодом «с последнего ответа»), пороги «роста» модулей дневника — согласовать с врачом. + ## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами **Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34). **Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача. diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index 269dfa3..950fa0f 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -51,6 +51,15 @@ async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient) assert await repo.get_profile(s, first["token"]) is None +async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"] + async with session.get_clinical_session() as s: + profile = await repo.get_profile(s, token) + assert profile.modules == ["veteran"] and not profile.doctor_id + assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False + assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422 + + async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None: token = (await client.post("/api/v1/auth/anonymous")).json()["token"] replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"] @@ -142,7 +151,7 @@ async def test_questionnaires_are_off_without_release_or_demo_flag( async with session.get_clinical_session() as s: await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1") q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"] - assert q == {"enabled": False, "intake": "none", "diary_done": False} + assert q == {"enabled": False, "intake": "none", "diary_done": False, "diary_next": "morning"} async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: @@ -158,7 +167,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp await press() await press("consent:yes") buttons = await press("age:adult") - assert any(b.startswith("iq:") and ":cssrs_q1:" in b for b in buttons) + # Stage 1 starts with PHQ-2, in the order of the MVP questionnaire. + assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons) assert "intake:help" in buttons and "intake:pause" in buttons q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"] assert q["enabled"] and q["intake"] == "active" @@ -169,7 +179,8 @@ async def test_instrument_catalog_describes_intake_items(client: AsyncClient) -> phq = body["items"]["phq9_3"] assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"] assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety" - assert body["items"]["sleep_hours"]["numeric"] and body["items"]["comment"]["optional"] + assert body["items"]["sleep_hours"]["numeric"] and body["items"]["alcohol_portions"]["numeric"] + assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary" assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale" drinks = body["standard_drinks"] assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"]) @@ -186,3 +197,19 @@ async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClie ) assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90 assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values()) + + +async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + # An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules. + monkeypatch.setenv("INTAKE_DEMO", "1") + token = "c" * 32 + + async def press(button: str | None = None) -> list[str]: + event = {"type": "button", "button": button} if button else {"type": "start"} + replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"] + return [b["data"] for r in replies for row in r["buttons"] for b in row] + + assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"] + await press() + await press("consent:yes") + assert any(":phq9_1:" in b for b in await press("age:adult")) diff --git a/tests/test_dialog_intake.py b/tests/test_dialog_intake.py index 37714eb..09c1bc0 100644 --- a/tests/test_dialog_intake.py +++ b/tests/test_dialog_intake.py @@ -94,6 +94,17 @@ async def complete_intake(e, choices=None): raise AssertionError("intake did not terminate") +async def to_cssrs(e, choices=None): + """Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S.""" + replies = await press(e, "intake:start") + for _ in range(20): + button = answer_button(replies, {"trauma": 1, **(choices or {})}) + if ":cssrs_" in button: + return replies + replies = await press(e, button) + raise AssertionError("C-SSRS not reached") + + async def test_minimal_intake_raw_data_not_imputed_and_restart(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) (row,) = await rows() @@ -111,13 +122,13 @@ async def test_pause_restart_and_duplicate_callbacks(released): old = answer_button(replies) await asyncio.gather(press(released, old), press(released, old)) (row,) = await rows() - assert row.answers == {"cssrs_q1": 0} + assert row.answers == {"phq9_1": 0} await press(released, "intake:pause") restarted = Engine(gateway=LLMGateway(MockTasks())) replies = await press(restarted, "intake:resume") - assert "cssrs_q2" in answer_button(replies) + assert "phq9_2" in answer_button(replies) await press(restarted, old) - assert (await rows())[0].answers == {"cssrs_q1": 0} + assert (await rows())[0].answers == {"phq9_1": 0} assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) @@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal): if signal == "help": replies = await press(released, "intake:help") elif signal == "cssrs": + replies = await to_cssrs(released) for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): replies = await press(released, answer_button(replies, {item: 0})) else: @@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released): async def test_low_risk_shows_safety_plan_but_moderate_stops(released): - replies = await press(released, "intake:start") + replies = await to_cssrs(released) replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) replies = await press(released, answer_button(replies)) replies = await press(released, answer_button(replies)) assert any(r.kind == "safety_plan" for r in replies) - assert "phq9_1" in answer_button(replies) + # The screener ends stage 1; the context questions follow (sex is known from the profile). + assert ":tbi:" in answer_button(replies) async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch): @@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp monkeypatch.setattr(integrations, "outbox", Failing()) await jobs.run_until_now() tasks = await rows(QuestionnaireTask) - assert len(tasks) == 2 - risk = next(t for t in tasks if t.kind == "cssrs_since_contact") - assert risk.delivery and risk.sent_at is None + # 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS. + assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"} + morning = next(t for t in tasks if t.kind == "diary_morning") + assert morning.delivery and morning.sent_at is None outbox = MemoryOutbox() monkeypatch.setattr(integrations, "outbox", outbox) await jobs.run_until_now() @@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp assert len(await rows()) == 2 -async def test_missing_since_contact_is_manual_review_not_recent(released): +async def test_since_contact_risk_check_opens_the_evening_diary(released): async with session.get_clinical_session() as s: task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW) task_id = task.id replies = await released.handle( TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) ) - (row,) = await rows() - assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers - assert row.details["stop_reason"] == "cssrs_since_contact_required" - assert not answer_button(replies) + assert any("последнего ответа" in r.text for r in replies) + for _ in range(5): + button = answer_button(replies) + if ":cssrs_" not in (button or ""): + break + replies = await press(released, button) + risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") + assert risk.status == "complete" and risk.answers == { + "cssrs_q1": 0, + "cssrs_q2": 0, + "cssrs_q6_lifetime": 0, + "cssrs_q6_recent": 0, + } + assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True + assert ":mood:" in answer_button(replies) + assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows()) async def test_disabled_or_no_adult_confirmation_cannot_start(released): @@ -256,6 +282,20 @@ async def review_risk_record(e): ) +async def run_diary(e, replies, choices=None, numbers=("6,5", "2")): + """Answers the open diary questions; numeric items take `numbers` in turn.""" + numbers = list(numbers) + for _ in range(30): + button = answer_button(replies, choices) + if button: + replies = await press(e, button) + elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers: + replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0))) + else: + return replies + raise AssertionError("diary did not terminate") + + async def test_doctor_review_is_audited_and_unblocks_diary(released): from fastapi import HTTPException @@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released): from app.db.audit import AuditLog await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) - await jobs.run_until_now() + async with session.get_clinical_session() as s: + (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True + replies = await run_diary(released, await press(released, "menu:checkin")) + # Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist. + replies = await press(released, "menu:checkin") + replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0}) risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") + assert risk.status == "review_required" with pytest.raises(HTTPException) as exc: await review( TOKEN, @@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released): assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required") await review_risk_record(released) await jobs.run_until_now() - assert any(r.kind == "diary" and r.status == "active" for r in await rows()) + assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows()) await history(TOKEN, "synthetic-doctor") audits = await rows(AuditLog) assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"} -async def test_diary_numeric_skip_and_not_prescribed(released): +async def test_diary_morning_then_evening_with_the_ptsd_module(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) - await press(released, "menu:checkin") - await review_risk_record(released) replies = await press(released, "menu:checkin") - for _ in range(30): - q = next( - (b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")), - None, - ) - if q == "comment": - button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip")) - replies = await press(released, button) - elif q: - replies = await press(released, answer_button(replies, {"medication": 3})) - elif any("часов" in r.text for r in replies): - invalid = await released.handle(TOKEN, Event(type="text", text="nan")) - assert any("число часов" in r.text for r in invalid) - replies = await released.handle(TOKEN, Event(type="text", text="6,5")) - else: - break - diary = next(r for r in await rows() if r.kind == "diary") - assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5 - assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers - assert diary.answers["comment"] is None + assert any("часов" in r.text for r in replies) + invalid = await released.handle(TOKEN, Event(type="text", text="nan")) + assert any("число часов" in r.text for r in invalid) + replies = await run_diary(released, invalid, {"sleep_quality": 3}) + assert any("утро" in r.text for r in replies) + morning = next(r for r in await rows() if r.kind == "diary_morning") + assert morning.status == "complete" + assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0} + replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2}) + evening = next(r for r in await rows() if r.kind == "diary_evening") + assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed" + assert "medication_reason" not in evening.answers + assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers) + assert evening.details["interpretation"]["kind"] == "diary_evening" + replies = await press(released, "menu:checkin") + assert any("уже заполнен" in r.text for r in replies) -async def test_high_risk_diary_asks_safety_first_and_stops(released): +async def test_depression_module_item9_stops_with_crisis(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) async with session.get_clinical_session() as s: - (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True - await press(released, "menu:checkin") - await review_risk_record(released) - replies = await press(released, "menu:checkin") - button = answer_button(replies) - assert "death_thoughts" in button - replies = await press(released, button) + await repo.upsert_profile(s, token=TOKEN, modules=["depression"]) + monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) + await run_diary(released, await press(released, "menu:checkin")) + replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1}) assert any(r.kind == "crisis" for r in replies) - assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line) - diary = next(r for r in await rows() if r.kind == "diary") - assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted" + evening = next(r for r in await rows() if r.kind == "diary_evening") + assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted" async def test_unknown_sex_goes_to_review_without_a_zone(released): @@ -344,7 +380,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released): path.write_text(yaml.safe_dump(data, allow_unicode=True)) await press(released, "intake:resume") (row,) = await rows() - assert row.status == "review_required" and row.answers == {"cssrs_q1": 0} + assert row.status == "review_required" and row.answers == {"phq9_1": 0} async def test_followup_delta_uses_comparable_complete_baseline(released): @@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released): data = yaml.safe_load(path.read_text()) data["cssrs_positive_policy"] = "stop_any" path.write_text(yaml.safe_dump(data, allow_unicode=True)) - replies = await press(released, "intake:start") + replies = await to_cssrs(released) replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) assert any(r.kind == "crisis" for r in replies) - assert (await rows())[0].answers == {"cssrs_q1": 1} + assert (await rows())[0].answers["cssrs_q1"] == 1 -async def test_evening_schedule_uses_moscow_date(released, monkeypatch): +async def test_diary_schedule_uses_moscow_date(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) - monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59)) + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59)) await jobs.plan() assert await rows(QuestionnaireTask) == [] - monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0)) + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0)) await jobs.plan() - assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"} + assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"} + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0)) + await jobs.plan() + assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"} async def test_resumed_answer_keeps_original_period(released, monkeypatch): @@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch): replies = await press(released, "intake:resume") await press(released, answer_button(replies)) (record,) = await rows() - metadata = record.details["answer_metadata"]["cssrs_q2"] - assert metadata["period_start"] == "2026-09-03" + metadata = record.details["answer_metadata"]["phq9_2"] + assert metadata["period_start"] == "2026-09-19" assert metadata["period_end"] == "2026-10-03" assert metadata["answered_at"].startswith("2026-10-04") + + +async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released): + from app.api.patient_web import my_results + + choices = {f"phq9_{i}": 2 for i in range(1, 9)} + choices.update(trauma=1, tbi=1, psychotropics=1) + replies = await complete_intake(released, choices) + assert any("Предположение депрессии" in r.text for r in replies) + assert any("не диагноз" in r.text for r in replies) + (row,) = await rows() + interpretation = row.details["interpretation"] + assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"] + async with session.get_clinical_session() as s: + # A doctor's modules (diagnosis) stay; the screening only adds to them. + assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"] + results = await my_results(TOKEN) + assert results.conclusion.title == interpretation["title"] + assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден" + + +async def test_without_a_doctor_the_screening_defines_the_modules(released): + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"]) + replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + assert any("не выявлено" in r.text for r in replies) + async with session.get_clinical_session() as s: + assert (await repo.get_profile(s, TOKEN)).modules == [] diff --git a/tests/test_dialog_meds.py b/tests/test_dialog_meds.py index 803ac9d..b36c094 100644 --- a/tests/test_dialog_meds.py +++ b/tests/test_dialog_meds.py @@ -202,6 +202,14 @@ async def test_side_effect_other_free_text(engine: Engine) -> None: assert (await checkin()).side_effects == ["headache", "other"] +async def test_side_effect_description_is_optional(engine: Engine) -> None: + reminder = await system(engine, "med_reminder") + r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other") + assert r[0].kind == "question" # the client shows an (optional) input for it + await press(engine, "text:skip") + assert (await checkin()).side_effects == ["other"] and (await get_state())[0] == "idle" + + async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None: async with session.get_clinical_session() as s: await repo.upsert_checkin( diff --git a/tests/test_intake_runtime.py b/tests/test_intake_runtime.py index 5924c84..f8b7641 100644 --- a/tests/test_intake_runtime.py +++ b/tests/test_intake_runtime.py @@ -56,28 +56,51 @@ def test_medication_schedule_overlapping_and_delayed_completion(): ) -def test_diary_consecutive_days_and_baseline_do_not_include_bad_window(): - today = date(2026, 10, 3) - history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)} - for i in range(3): - history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"} - assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"} - history[today - timedelta(days=1)] = {} - assert diary_flags(history, today) == () - history[today]["death_thoughts"] = 1 - assert diary_flags(history, today) == ("suicide_assessment_now",) +def norm_and_week(norm: dict, week: dict, today=date(2026, 10, 30)): + """14 days of personal norm starting 27 days ago, then the last 7 days with other values.""" + start = today - timedelta(days=27) + history = {start + timedelta(days=i): dict(norm) for i in range(14)} + history.update({today - timedelta(days=i): dict(week) for i in range(7)}) + return history, today -def test_sleep_alternation_counts_but_threshold_is_strict(): +def test_weekly_mood_against_personal_norm(): + history, today = norm_and_week({"mood": 8}, {"mood": 5}) + assert "phq9_due_mood" in diary_flags(history, today) + history, today = norm_and_week({"mood": 8}, {"mood": 6}) + assert "phq9_due_mood" not in diary_flags(history, today) + # Inside the norm window there is no trend yet. + early = {date(2026, 10, 1) + timedelta(days=i): {"mood": 9 if i < 3 else 1} for i in range(10)} + assert "phq9_due_mood" not in diary_flags(early, date(2026, 10, 10)) + + +def test_module_trends_bring_full_scales_forward(): + ptsd = {"dpcl_1": 1, "dpcl_7": 1, "dpcl_10": 1, "dpcl_18": 1} + history, today = norm_and_week(ptsd, {k: 2 for k in ptsd}) + assert "pcl5_due_ptsd" in diary_flags(history, today) + history, today = norm_and_week({"dphq_4": 0}, {"dphq_4": 1}) + assert "phq9_due_depression" in diary_flags(history, today) + + +def test_sleep_alcohol_and_medication_flags(): today = date(2026, 10, 3) - h = { - today: {"nightmares": 3}, - today - timedelta(days=1): {"sleep_hours": 3.5}, - today - timedelta(days=2): {"sleep_hours": 3.5}, - } - assert "sleep_review" in diary_flags(h, today) - h[today - timedelta(days=1)]["sleep_hours"] = 4 - assert "sleep_review" not in diary_flags(h, today) + nights = {today - timedelta(days=i): {"sleep_hours": 7, "sleep_quality": 0} for i in range(3)} + nights[today - timedelta(days=1)] = {"sleep_hours": 3.5, "sleep_quality": 2} + assert "sleep_review" in diary_flags(nights, today) + nights[today - timedelta(days=1)]["sleep_hours"] = 4 + assert "sleep_review" not in diary_flags(nights, today) + + drinks = {today: {"alcohol_portions": 6}, today - timedelta(days=2): {"alcohol_portions": 7}} + assert set(diary_flags(drinks, today, medication_active=True)) >= {"alcohol_on_medication", "rus_audit_due_alcohol"} + assert "alcohol_on_medication" not in diary_flags(drinks, today) + + missed = {today - timedelta(days=i): {"medication": "no"} for i in range(3)} + assert "medication_contact" in diary_flags(missed, today) + week = {today - timedelta(days=i): {"medication": "yes" if i % 3 else "no"} for i in range(7)} + assert "adherence_review" in diary_flags(week, today) + assert diary_flags({today: {"medication": "no", "medication_reason": "side_effects"}}, today) == ( + "side_effects_review", + ) def test_total_threshold_and_zone_are_separate_decisions(): @@ -96,15 +119,60 @@ def test_total_threshold_and_zone_are_separate_decisions(): IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2}) -def test_module_rotation_never_drops_suicide_question(): +def test_diary_parts_and_module_rotation(): from app.core.intake_questions import diary_questions - first = { - q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100) - } - second = { - q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101) - } - assert "death_thoughts" in first & second - assert "nightmares" in first and "nightmares" not in second - assert "burden" in first and "burden" not in second + assert [q.id for q in diary_questions("morning", ["veteran", "depression"])] == [ + "sleep_hours", + "sleep_quality", + "alcohol_portions", + ] + evening = [q.id for q in diary_questions("evening", ["veteran", "depression"], day_index=0)] + assert evening == ["mood", "medication", "medication_reason", "dphq_1", "dphq_2", "dphq_3"] + [ + "dpcl_1", + "dpcl_7", + "dpcl_10", + "dpcl_18", + ] + assert [q.id for q in diary_questions("evening", ["depression"], day_index=5)][-3:] == [ + "dphq_7", + "dphq_8", + "dphq_9", + ] + assert [q.id for q in diary_questions("evening", [], day_index=1)] == ["mood", "medication", "medication_reason"] + + +def test_demo_policy_is_separate_and_marked(monkeypatch): + from app.config import get_settings + + monkeypatch.setenv("INTAKE_DEMO", "1") + monkeypatch.setattr(get_settings(), "demo_clock", True) + demo = load_policy() + assert demo.approval and "DEMO" in demo.approval.reviewer and demo.cssrs_positive_policy == "graded" + monkeypatch.delenv("INTAKE_DEMO") + assert load_policy().approval is None + + +def test_full_scale_change_against_the_start_of_observation(): + from app.core.interpretation import scale_interpretation + + def phq(total, baseline, previous=None): + totals = {"phq9": {"complete": True, "total": total}} + return scale_interpretation( + "phq9", totals, baseline=baseline, previous=previous, policy=load_policy(), retriage_delta=5 + )["codes"] + + assert phq(4, 16) == ["minimal", "remission"] + assert phq(8, 16) == ["mild", "response"] + assert phq(11, 16) == ["moderate", "improved"] + assert phq(15, 16) == ["moderately_severe", "stable"] + assert phq(21, 10, previous=16) == ["severe", "worse"] + pcl = scale_interpretation( + "pcl5", + {"pcl5": {"complete": True, "total": 27}}, + baseline=45, + previous=40, + policy=load_policy(), + retriage_delta=10, + ) + assert pcl["codes"] == ["below", "response"] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6f796d6..96a5a4e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -4,8 +4,10 @@ import type { CrisisResource, DemoPersona, DialogEvent, + Focus, MedsReference, Reply, + Results, TodayPlan, } from "./types"; @@ -41,12 +43,13 @@ const post = (path: string, body?: unknown) => export const api = { signInEmias: (code: string) => post("/auth/emias", { code }), signInEmiasDemo: (personaToken: string) => post("/auth/emias/demo", { persona_token: personaToken }), - signInAnonymous: () => post("/auth/anonymous"), + signInAnonymous: (focus: Focus) => post("/auth/anonymous", { focus }), demoPersonas: () => request("/demo/personas"), crisisResources: () => request("/crisis/resources"), catalog: () => request("/instruments/catalog"), medsReference: () => request("/meds/reference"), today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`), + results: (token: string) => request(`/me/${encodeURIComponent(token)}/results`), sendEvent: async (token: string, event: DialogEvent) => (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, outbox: async (token: string) => diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b0dd747..de5c3e5 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -21,6 +21,9 @@ export type DialogEvent = export type AuthMode = "emias" | "anonymous"; +/** What an anonymous person suspects; the backend maps it to a clinical module. */ +export type Focus = "depression" | "ptsd"; + export interface AuthResult { token: string; mode: AuthMode; @@ -67,6 +70,35 @@ export interface Questionnaires { enabled: boolean; intake: "none" | "active" | "paused" | "complete" | "review"; diary_done: boolean; + diary_next: "morning" | "evening" | null; +} + +// app/api/patient_web.py: /me/{token}/results — preliminary conclusion of stage 1 and the questionnaire log. +export interface Conclusion { + day: string; + title: string; + lines: string[]; + findings: { code: string; title: string }[]; + modules: string[]; + disclaimer: string; +} + +export interface HistoryItem { + id: string; + kind: string; + kind_title: string; + status: string; + status_title: string; + day: string; + started_at: string; + completed_at: string | null; + title: string | null; + lines: string[]; +} + +export interface Results { + conclusion: Conclusion | null; + history: HistoryItem[]; } export interface TodayPlan { diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 10dd890..1fde105 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -10,6 +10,7 @@ import { Icon, LogoMark, type IconName } from "./Icon"; * Layout from docs/mock (series 2). * phone: content → «Экстренная помощь» block → bottom tab bar (signed in) * desktop: navy sidebar | content | right rail (optional `aside` + emergency panel) + * bare: one centred column on every width (questionnaires) */ const TABS: { to: string; label: string; icon: IconName }[] = [ @@ -127,6 +128,7 @@ export function AppShell({ aside, focus = false, sos = true, + bare = false, }: { children: ReactNode; signedIn?: boolean; @@ -135,7 +137,20 @@ export function AppShell({ focus?: boolean; /** The crisis screen shows the hotlines itself, so it drops the bottom block. */ sos?: boolean; + /** Questionnaires: no sidebar, rail or tab bar — one centred column, so nothing distracts from the answers. */ + bare?: boolean; }) { + if (bare) + return ( +
+
+
+ {children} + {sos && } +
+
+
+ ); return (
diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index c323363..cf90800 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -28,6 +28,7 @@ const paths = { ), chevronRight: , + chevronDown: , arrowLeft: , close: , check: , diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx new file mode 100644 index 0000000..3927ac4 --- /dev/null +++ b/web/src/components/Toast.tsx @@ -0,0 +1,55 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react"; + +import { Icon } from "./Icon"; + +/* + * Short confirmations («отметил приём», «дневник сохранён») as a toast in the top-right corner instead of a separate + * confirmation page: the person lands on the home screen right away and still sees what happened. + */ + +interface Toast { + id: number; + text: string; +} + +const Ctx = createContext<(text: string) => void>(() => undefined); +const LIFETIME_MS = 6000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const next = useRef(0); + + const close = useCallback((id: number) => setToasts((all) => all.filter((t) => t.id !== id)), []); + const show = useCallback( + (text: string) => { + const id = ++next.current; + setToasts((all) => [...all.slice(-2), { id, text }]); + window.setTimeout(() => close(id), LIFETIME_MS); + }, + [close], + ); + + const value = useMemo(() => show, [show]); + return ( + + {children} +
+ {toasts.map((t) => ( +
+ +

{t.text}

+ +
+ ))} +
+
+ ); +} + +export function useToast(): (text: string) => void { + return useContext(Ctx); +} diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index ab28858..44e0c74 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -37,23 +37,21 @@ export const ru = { networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.", }, welcome: { - title: "Как вы хотите войти в приложение?", - lead: "Выберите удобный способ. Вы всегда можете сменить его позже в профиле.", + title: "Вход", emias: { - title: "Через ЕМИАС", - text: "Назначения врача подгружаются сами, врач видит ответы и получает сигнал при ухудшении, история доступна с любого устройства.", + title: "Войти через ЕМИАС", + perks: [ + "Назначения врача подгружаются автоматически", + "Врач видит ответы и получает сигнал при ухудшении", + "История доступна с любого устройства", + ], }, + or: "или", anonymous: { - title: "Анонимно", - text: "Не нужны имя, полис и телефон, врач ничего не видит, история только на этом устройстве.", + title: "Войти анонимно", + text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.", }, - disclaimer: "Сервис не ставит диагнозы и не назначает лечение. Решения о лечении принимает ваш врач.", - howTitle: "Как это работает", - how: [ - { title: "Врач подключает", text: "На приёме вы получаете код или входите анонимно" }, - { title: "Минута в день", text: "Напоминание о лекарстве и короткая отметка самочувствия" }, - { title: "Врач видит динамику", text: "И получает сигнал, если состоянию нужно внимание" }, - ], + disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.", }, emias: { title: "Введите код от врача", @@ -70,14 +68,12 @@ export const ru = { demoDay: (day: number) => `${day}-й день терапии`, }, anonymous: { - title: "Анонимный вход", - lead: "Вы сможете пользоваться основными функциями приложения без входа через ЕМИАС.", - yesTitle: "Что будет доступно:", - yes: ["Ежедневные отметки", "Напоминания", "Анкеты и помощь в кризисе"], - noTitle: "Чего не будет:", - no: ["Врач не увидит ответы", "История только на этом устройстве"], - warning: "После выхода историю восстановить нельзя.", - submit: "Продолжить анонимно", + title: "Что вы хотите проверить?", + note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.", + focus: { + depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" }, + ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" }, + }, submitting: "Создаём профиль…", }, today: { @@ -105,7 +101,6 @@ export const ru = { newMessage: "Новое сообщение", open: "Открыть", continue: "Продолжить", - surveyStep: (section: string, n: number) => `${section} · вопрос ${n}`, intakeStart: "Первичный опрос", intakeStartHint: "15–20 минут, можно прерваться и продолжить позже", intakeResume: "Продолжить первичный опрос", @@ -115,6 +110,9 @@ export const ru = { intakeDone: "Первичный опрос пройден", diary: "Дневник самочувствия", diaryHint: "2–3 минуты: настроение, сон, лекарства", + diaryPart: { morning: "Дневник — утро", evening: "Дневник — вечер" }, + diaryPartHint: { morning: "3 вопроса: сон и алкоголь", evening: "Настроение, лекарства и модули по результату опроса" }, + results: "Мой результат", diaryDone: "Дневник заполнен", therapyDay: (day: number) => `День ${day} лечения`, effectHint: "Когда ждать эффекта и что важно знать", @@ -187,6 +185,20 @@ export const ru = { wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.", logout: "Выйти", messages: "Сообщения", + result: "Предварительный результат", + resultNone: "Появится после первичного опроса.", + history: "История опросов", + historySub: "Все опросы и дневники с датами и результатами", + }, + results: { + title: "Мои результаты", + conclusion: "Предварительный результат", + conclusionDay: (d: string) => `Первичный опрос от ${d}`, + modules: { depression: "Модуль «Депрессия»", veteran: "Модуль «ПТСР»" }, + history: "История опросов", + empty: "Здесь появятся пройденные опросы.", + noResult: "Пройдите первичный опрос — после него здесь будет результат.", + toIntake: "Пройти первичный опрос", }, flow: { question: (n: number, total: number) => `Вопрос ${n} из ${total}`, @@ -194,6 +206,8 @@ export const ru = { textPlaceholder: "Напишите несколько слов", sending: "Отправляем…", doneTitle: "Готово", + resultTitle: "Результат", + sendEmpty: "Отправить без комментария", doneLead: "Врач увидит отметку.", breathe: "Вдох на 4 — выдох на 6", breatheIn: "Вдох", @@ -214,11 +228,12 @@ export const ru = { }, questionN: (n: number) => `Вопрос ${n}`, recall: (period: string) => `Период: ${period}`, - asOf: (date: string) => `Считайте от ${date}`, - saved: "Ответы сохраняются после каждого вопроса", hoursLabel: "Сколько часов", hoursUnit: "ч", hoursHint: "От 0 до 24, можно с половиной: 6,5", + portionsLabel: "Сколько порций", + portionsUnit: "порц.", + portionsHint: "Если не пили — 0. Можно дробное: 1,5", drinks: "Таблица стандартных порций", drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.", noticeTitle: "Опрос приостановлен", diff --git a/web/src/dialog/step.ts b/web/src/dialog/step.ts index f808031..a291a6a 100644 --- a/web/src/dialog/step.ts +++ b/web/src/dialog/step.ts @@ -7,6 +7,7 @@ import type { DialogButton, Reply } from "../api/types"; export type Step = | { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over + | { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home | { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] } | { kind: "crisis"; text: string; buttons: DialogButton[] } | { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] } @@ -26,7 +27,7 @@ export interface Survey { title: string; // the item text, verbatim footnotes: string[]; // «*…» explanations printed under the item on the paper form options: DialogButton[]; - input: "choice" | "number" | "hours" | "text"; + input: "choice" | "number" | "hours" | "portions" | "text"; anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item asOf?: string; // ISO date the recall period counts from skip?: DialogButton; @@ -107,7 +108,10 @@ function survey(batch: Reply[], qIndex: number): Survey | null { ? "choice" : skip ? "text" - : "hours"; + : // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours. + /порци/i.test(title) + ? "portions" + : "hours"; return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause }; } @@ -142,6 +146,9 @@ export function classify(batch: Reply[]): Step | null { const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true); const texts = batch.map((r) => r.text); if (qIndex === -1) { + // Without buttons the engine waits for free text only when it marks the reply as a question; anything else + // («отметил приём») is a confirmation. + if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) }; const { lead, title } = titled(texts); return { kind: "text", lead, title }; } diff --git a/web/src/main.tsx b/web/src/main.tsx index 7fac6f9..0b98700 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -10,6 +10,7 @@ import { createRoot } from "react-dom/client"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import { HelpProvider } from "./components/HelpSheet"; +import { ToastProvider } from "./components/Toast"; import { DialogProvider } from "./dialog/DialogContext"; import { AnonymousSignIn } from "./screens/AnonymousSignIn"; import { Doctor } from "./screens/Doctor"; @@ -19,6 +20,7 @@ import { Flow } from "./screens/Flow"; import { DoseReminder, Meds } from "./screens/Meds"; import { Messages } from "./screens/Messages"; import { Profile } from "./screens/Profile"; +import { Results } from "./screens/Results"; import { Today } from "./screens/Today"; import { Welcome } from "./screens/Welcome"; import { SessionProvider, useSession } from "./session/SessionContext"; @@ -43,6 +45,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> @@ -54,9 +57,11 @@ createRoot(document.getElementById("root")!).render( + + diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx index 4736702..097248f 100644 --- a/web/src/screens/AnonymousSignIn.tsx +++ b/web/src/screens/AnonymousSignIn.tsx @@ -2,15 +2,18 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "../api/client"; +import type { Focus } from "../api/types"; import { AppShell, Notice, TopBar } from "../components/AppShell"; import { Icon, type IconName } from "../components/Icon"; import { ru } from "../content/ru"; import { useSession } from "../session/SessionContext"; -const YES_ICONS: IconName[] = ["calendar", "pill", "doc"]; -const NO_ICONS: IconName[] = ["user", "doc"]; +const FOCUS: { id: Focus; icon: IconName }[] = [ + { id: "depression", icon: "smile" }, + { id: "ptsd", icon: "heart" }, +]; -// A3 · Анонимный вход +// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос export function AnonymousSignIn() { const t = ru.anonymous; const navigate = useNavigate(); @@ -18,11 +21,11 @@ export function AnonymousSignIn() { const [busy, setBusy] = useState(false); const [error, setError] = useState(false); - const start = async () => { + const start = async (focus: Focus) => { setBusy(true); setError(false); try { - signIn(await api.signInAnonymous()); + signIn(await api.signInAnonymous(focus)); navigate("/flow", { replace: true }); } catch { setError(true); @@ -34,58 +37,28 @@ export function AnonymousSignIn() {
navigate("/")} /> -
-
-
- - - +

{t.title}

-

{t.lead}

-
- {t.warning} -
-
-
-

- - - - {t.yesTitle} -

-
    - {t.yes.map((item, i) => ( -
  • - - {item} -
  • + +
    + {FOCUS.map(({ id, icon }) => ( + ))} -
-
+
-
-

- - - - {t.noTitle} -

-
    - {t.no.map((item, i) => ( -
  • - - {item} -
  • - ))} -
-
- - {error && {ru.common.networkError}} - - -
+ {error && {ru.common.networkError}} +

{busy ? t.submitting : t.note}

diff --git a/web/src/screens/Flow.tsx b/web/src/screens/Flow.tsx index b26b45c..50d15fb 100644 --- a/web/src/screens/Flow.tsx +++ b/web/src/screens/Flow.tsx @@ -1,15 +1,15 @@ -import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; -import type { Catalog, DialogButton, Section } from "../api/types"; +import type { Catalog, DialogButton } from "../api/types"; import { AppShell, Notice, TopBar } from "../components/AppShell"; import { HotlineCard, telHref, useHelp } from "../components/HelpSheet"; +import { useToast } from "../components/Toast"; import { Icon, type IconName } from "../components/Icon"; import { useCatalog } from "../content/reference"; import { ru } from "../content/ru"; import { useDialog } from "../dialog/DialogContext"; import { classify, lines, type Step, type Survey } from "../dialog/step"; -import { dayMonth } from "./useToday"; /* * Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5), @@ -17,16 +17,6 @@ import { dayMonth } from "./useToday"; * versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник). */ -export const SECTION_ICON: Record = { - safety: "shield", - mood: "smile", - trauma: "heart", - alcohol: "info", - about: "user", - diary: "book", - scale: "clipboard", -}; - const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g; function RichText({ text }: { text: string }) { @@ -91,19 +81,6 @@ function Progress({ n, total, adaptive = false }: { n: number; total: number; ad ); } -function FormChip({ section, title }: { section: Section; title?: string }) { - return ( -
- - - {ru.flow.sections[section]} - - {/* The C-SSRS form title names suicide risk in capitals: the patient sees the section name only. */} - {title && section !== "safety" && {title}} -
- ); -} - // RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml). function DrinksTable({ table }: { table: NonNullable }) { return ( @@ -135,7 +112,8 @@ function DrinksTable({ table }: { table: NonNullable

- {table.title} {table.note} + {/* The form name stays hidden while answering: only the definition of a standard drink is shown. */} + {table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}

); @@ -248,6 +226,9 @@ function NumberScale({ ); } +// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py). +const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" }; + function FreeText({ skip, busy, @@ -291,20 +272,34 @@ function FreeText({ ); } -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 (
}} >
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))} aria-describedby="hours-hint" /> - +

- {ru.flow.hoursHint} + {f.hint}

@@ -422,7 +413,6 @@ function SurveyAct({ )} -

{ru.flow.saved}

); } @@ -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 (
-

{ru.flow.doneTitle}

-
- -
+

{head}

+ {rest.length > 0 && ( +
+ +
+ )} +
); } +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() { ) : (
); + } else if (step.kind === "ack") { + content =
; } else if (step.kind === "idle") { - content = ; + content = navigate("/results")} />; } else if (step.kind === "notice") { content = (
@@ -556,7 +588,7 @@ export function Flow() { ) : item ? ( ) : null; - const top = {progress}; + const top = {progress}; // 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 = ; - act = ; + act = ; break; - case "choice": { - const form = step.formId ? catalog?.forms[step.formId] : undefined; - lead = ( - <> - {form && } - - - ); + case "choice": + lead = ; act = ; break; - } } content = (
@@ -699,7 +724,7 @@ export function Flow() { } return ( - + {content} ); diff --git a/web/src/screens/Profile.tsx b/web/src/screens/Profile.tsx index 7f05273..b319157 100644 --- a/web/src/screens/Profile.tsx +++ b/web/src/screens/Profile.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { api } from "../api/client"; import { AppShell } from "../components/AppShell"; import { Icon } from "../components/Icon"; import { ru } from "../content/ru"; @@ -14,8 +15,18 @@ export function Profile() { const { session, signOut } = useSession(); const dialog = useDialog(); const [confirm, setConfirm] = useState(false); + const [conclusion, setConclusion] = useState(null); const ref = useRef(null); + // The preliminary conclusion of stage 1 is part of the profile until the doctor's diagnosis replaces it. + useEffect(() => { + if (!session) return; + api + .results(session.token) + .then((r) => setConclusion(r.conclusion?.title ?? null)) + .catch(() => undefined); + }, [session]); + useEffect(() => { const d = ref.current; if (!d) return; @@ -68,6 +79,26 @@ export function Profile() {

{t.data}

+ + + {expanded && ( +
+

+ {h.status_title} + {h.completed_at ? ` · ${new Date(h.completed_at).toLocaleString("ru-RU")}` : ""} +

+ {h.lines.map((l) => ( +

{l}

+ ))} +
+ )} + + ); + })} + +
+ )} +
+ + ); +} diff --git a/web/src/screens/Today.tsx b/web/src/screens/Today.tsx index c4c06f1..ec8c801 100644 --- a/web/src/screens/Today.tsx +++ b/web/src/screens/Today.tsx @@ -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" ? ( navigate("/flow")} /> @@ -240,10 +237,10 @@ export function Today() { {diaryDue && waiting?.kind !== "survey" && ( go("menu:checkin", t.diary)} + onClick={() => go("menu:checkin", q.diary_next ? t.diaryPart[q.diary_next] : t.diary)} /> )} {plan && !surveys && !plan.checkin_done && ( diff --git a/web/src/screens/Welcome.tsx b/web/src/screens/Welcome.tsx index abe81fc..136646b 100644 --- a/web/src/screens/Welcome.tsx +++ b/web/src/screens/Welcome.tsx @@ -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 ( - -
-
+
+
+
+ + + {ru.product.name} + {ru.product.tagline} + +
+ +

{t.title}

-

{t.lead}

-
-
- - -
-
-

- {t.howTitle} -

-
    - {t.how.map((step, i) => ( -
  1. - {i + 1} - - {step.title} - {step.text} - -
  2. - ))} -
+
+ {t.or} +
+ +
+ +

{t.anonymous.text}

+
-

{t.disclaimer}

-
-
+
+ + {emergency && ( + + {emergency.phone} + + )} +
+

{t.disclaimer}

+ +
); } diff --git a/web/src/theme/global.css b/web/src/theme/global.css index f91aeca..5d351c2 100644 --- a/web/src/theme/global.css +++ b/web/src/theme/global.css @@ -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); + } +}