diff --git a/.env.example b/.env.example index a5b05af..6e33092 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ DOCTOR_NOTIFY_CHAT_ID= # --- Demo virtual clock (ADR-005) DEMO_CLOCK=true +# Questionnaires of the intake (#53) on synthetic data, demo only (needs DEMO_CLOCK). The clinical release gate in +# config/intake_runtime.yaml stays closed; without a signed routing policy every finished intake goes to review. +INTAKE_DEMO=false TZ=Europe/Moscow # --- Gitea (for Claude Code / team workflow, not used by the app) diff --git a/app/api/patient_web.py b/app/api/patient_web.py index 3e58ec5..e69b6ab 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -1,4 +1,5 @@ -"""Patient web app: sign-in and public reference data (`/api/v1/auth/*`, `/api/v1/crisis/resources`). +"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`, +`/crisis/resources`, `/instruments/catalog`, `/meds/reference`). The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...` exactly like the web console, and consent, onboarding, scales and check-ins come from the engine. @@ -18,7 +19,8 @@ import logging import secrets from collections import defaultdict from datetime import date, datetime, time, timedelta -from typing import Literal +from typing import Any, Literal +from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException from pydantic import BaseModel, ConfigDict, Field @@ -26,10 +28,14 @@ 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_runtime import intake_enabled, load_runtime +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 from app.db.clinical import DailyCheckin, Dose, Prescription from app.db.identity import TOKEN_LEN +from app.db.intake import QuestionnaireRecord from app.db.session import get_clinical_session from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock @@ -80,15 +86,21 @@ async def _seed_demo_persona(token: str) -> None: token=token, doctor_id=fixtures.DOCTOR_ID, modules=list(item.modules), - therapy_start=today, + therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today), next_visit=today + timedelta(days=15), ) if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None: return for p in card.prescriptions: - rx = Prescription(token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=today) + rx = Prescription( + token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today + ) s.add(rx) await s.flush() + # The dose history of the dashboard story (days before today), so the week marks match the doctor's card. + for d in card.doses: + if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today: + s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status)) for t in p.schedule.get("times", []): hh, mm = (int(x) for x in t.split(":")) s.add( @@ -147,6 +159,18 @@ class PrescriptionItem(BaseModel): dose: str times: list[str] week: list[WeekDay] # Mon..Sun of the current week + ref_id: str | None = None # entry of /meds/reference, if the drug is covered + indication: Literal["depression", "ptsd"] | None = None + start_date: date | None = None + therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14) + + +class Questionnaires(BaseModel): + """Questionnaire state for the home screen. Answers and scores are not exposed here.""" + + enabled: bool + intake: Literal["none", "active", "paused", "complete", "review"] + diary_done: bool class TodayOut(BaseModel): @@ -157,6 +181,19 @@ class TodayOut(BaseModel): next_visit: date | None doses: list[DoseItem] prescriptions: list[PrescriptionItem] + questionnaires: Questionnaires + + +def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]: + intake = [r for r in records if r.kind == "intake"] + if any(r.status == "complete" for r in intake): + return "complete" + if not intake: + return "none" + last = max(intake, key=lambda r: r.started_at).status + if last in ("active", "paused"): + return last + return "review" if last in ("review_required", "interrupted") else "none" def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]: @@ -187,6 +224,7 @@ async def my_today(token: str) -> TodayOut: ) ).all() checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today)) + records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all()) by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions} today_doses: list[DoseItem] = [] @@ -200,23 +238,40 @@ async def my_today(token: str) -> TodayOut: DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status) ) 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) + + def item(p: Prescription) -> PrescriptionItem: + ref = find_drug(p.drug) + return PrescriptionItem( + drug=p.drug, + dose=p.dose, + times=list((p.schedule or {}).get("times", [])), + week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week], + ref_id=ref["id"] if ref else None, + indication=indication_for(modules, ref) if ref else None, + start_date=p.start_date, + therapy_day=(today - p.start_date).days + 1 if p.start_date else None, + ) + return TodayOut( today=today, now=now.strftime("%H:%M"), has_doctor=profile is not None, - checkin_done=checkin is not None and checkin.mood is not None, + 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, prescriptions=[ - PrescriptionItem( - drug=p.drug, - dose=p.dose, - times=list((p.schedule or {}).get("times", [])), - week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week], - ) + item(p) for p in prescriptions 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, + intake=_intake_state(records), + diary_done=diary_done, + ), ) @@ -224,3 +279,19 @@ async def my_today(token: str) -> TodayOut: async def crisis_resources() -> list[CrisisResource]: """Help lines for the always-visible «help now» button — the same list as the fixed crisis script.""" return crisis_reply([], "patient").resources + + +@router.get("/instruments/catalog", response_model=Catalog) +async def instruments_catalog() -> Catalog: + """Form, position and recall period of every questionnaire item, for the question screen header.""" + return catalog() + + +@router.get("/meds/reference") +async def meds_reference() -> dict[str, Any]: + """Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags. + + The internal rule parameters stay on the server. + """ + ref = load_reference() + return {k: v for k, v in ref.items() if k != "rules"} diff --git a/app/core/instrument_catalog.py b/app/core/instrument_catalog.py new file mode 100644 index 0000000..05c965c --- /dev/null +++ b/app/core/instrument_catalog.py @@ -0,0 +1,138 @@ +"""Read-only catalog of questionnaire items for patient clients (web app). + +The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id +(`iq:::`, `scale:::`). A client looks the item up here to show which form it belongs +to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of +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.scales import load_scale + +Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"] + +# Forms whose item order depends on earlier answers: a client shows the position without «из N». +ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"} + + +class FormInfo(BaseModel): + id: str + title: str + section: Section + recall_period: str | None = None + instruction: str | None = None + + +class ItemInfo(BaseModel): + form: str + index: int # 1-based position in the form + total: int + adaptive: bool + optional: bool = False + numeric: bool = False + + +class DrinksTable(BaseModel): + """RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form).""" + + title: str + header: list[str] + rows: list[tuple[str, list[str]]] + note: str + + +class Catalog(BaseModel): + forms: dict[str, FormInfo] + items: dict[str, ItemInfo] + standard_drinks: DrinksTable | None + + +def _drinks() -> DrinksTable | None: + raw = texts().get("standard_drinks") + if not raw: + return None + lines = [line.strip() for line in raw.splitlines() if line.strip()] + title, header_line, *rest = lines + rows = [line for line in rest if ":" in line] + note = " ".join(line for line in rest if ":" not in line) + return DrinksTable( + title=title, + header=[h.strip() for h in header_line.split("·")], + rows=[ + (label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows) + ], + note=note, + ) + + +@lru_cache(maxsize=1) +def catalog() -> Catalog: + forms: dict[str, FormInfo] = {} + items: dict[str, ItemInfo] = {} + + def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()): + forms[form.id] = form + for i, item in enumerate(ids, 1): + items.setdefault( + item, + ItemInfo( + form=form.id, + index=i, + total=len(ids), + adaptive=form.id in ADAPTIVE, + optional=item in optional, + numeric=item in numeric, + ), + ) + + cssrs = load_cssrs_russian() + add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items]) + + intake_forms: dict[str, Any] = content()["forms"] + for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")): + raw = intake_forms.get(sid) or {} + scale = load_scale(sid) + add( + FormInfo( + id=sid, + title=raw.get("title") or scale.title, + section=section, + recall_period=raw.get("recall_period") or scale.recall_period, + instruction=raw.get("instruction") or scale.instruction, + ), + list(scale.item_ids), + ) + 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, [])] + 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")}, + ) + + # Scales of the onboarding / scheduled sessions (`scale::...` buttons): the form header only. + for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")): + scale = load_scale(path.stem) + forms.setdefault( + scale.id, + FormInfo( + id=scale.id, + title=scale.title, + section="scale", + recall_period=scale.recall_period, + instruction=scale.instruction, + ), + ) + return Catalog(forms=forms, items=items, standard_drinks=_drinks()) diff --git a/app/core/intake_runtime.py b/app/core/intake_runtime.py index 63112ed..e7b82e3 100644 --- a/app/core/intake_runtime.py +++ b/app/core/intake_runtime.py @@ -1,5 +1,6 @@ """Versioned release gate and deterministic follow-up planning.""" +import os from calendar import monthrange from datetime import date, timedelta from pathlib import Path @@ -64,8 +65,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy: return RuntimePolicy.model_validate(yaml.safe_load(path.read_text())) +def demo_intake() -> bool: + """Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data. + + 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 + + def intake_enabled() -> bool: - return load_runtime().released and load_policy().approval is not None + return (load_runtime().released and load_policy().approval is not None) or demo_intake() def month_before(day: date, months: int = 1) -> date: diff --git a/app/core/meds_reference.py b/app/core/meds_reference.py new file mode 100644 index 0000000..0804a2c --- /dev/null +++ b/app/core/meds_reference.py @@ -0,0 +1,35 @@ +"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py). + +Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long +the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the +doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file). +""" + +import json +from functools import lru_cache +from typing import Any, Literal + +from app.config import get_settings + +Indication = Literal["depression", "ptsd"] + + +@lru_cache(maxsize=1) +def load_reference() -> dict[str, Any]: + path = get_settings().data_dir / "meds" / "meds_reference.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def find_drug(name: str) -> dict[str, Any] | None: + """Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered.""" + key = name.strip().split()[0].casefold() if name.strip() else "" + for drug in load_reference()["drugs"]: + if key in (drug["id"], drug["name_ru"].casefold()): + return drug + return None + + +def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None: + """The indication the patient is followed for, among those the drug has in the guidelines.""" + preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"] + return next((i for i in preferred if i in drug["indications"]), None) diff --git a/data/meds/meds_reference.json b/data/meds/meds_reference.json new file mode 100644 index 0000000..ad77b2b --- /dev/null +++ b/data/meds/meds_reference.json @@ -0,0 +1,1169 @@ +{ + "version": "draft-2026-10-04", + "status": "черновик: собран по КР Минздрава, тексты и параметры вычитывает психиатр", + "sources": [ + { + "id": "kr_depression_2024", + "title": "Депрессивный эпизод, рекуррентное депрессивное расстройство", + "year": 2024, + "file": "docs/source/rag/kr_depression.pdf" + }, + { + "id": "kr_ptsd_2023", + "title": "Посттравматическое стрессовое расстройство", + "year": 2023, + "kr_id": 753, + "file": "docs/source/rag/kr_ptsd.pdf" + } + ], + "drugs": [ + { + "id": "paroxetine", + "name_ru": "Пароксетин", + "group": "Антидепрессант", + "class": "СИОЗС", + "indications": { + "depression": { + "start_mg": 20, + "min_mg": 20, + "max_mg": 50, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 20, + "max_mg": 50, + "off_label": false, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы", + "Головная боль" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Необычные кровотечения или синяки" + ], + "monitoring": [], + "withdrawal_risk": "high", + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "sertraline", + "name_ru": "Сертралин", + "group": "Антидепрессант", + "class": "СИОЗС", + "indications": { + "depression": { + "start_mg": 50, + "min_mg": 50, + "max_mg": 200, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 25, + "max_mg": 200, + "off_label": false, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы", + "Головная боль" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Необычные кровотечения или синяки" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "fluvoxamine", + "name_ru": "Флувоксамин", + "group": "Антидепрессант", + "class": "СИОЗС", + "indications": { + "depression": { + "start_mg": 50, + "min_mg": 100, + "max_mg": 300, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы", + "Головная боль" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Необычные кровотечения или синяки" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "Стартовая доза по табл. 2: 50–100 мг" + }, + { + "id": "fluoxetine", + "name_ru": "Флуоксетин", + "group": "Антидепрессант", + "class": "СИОЗС", + "indications": { + "depression": { + "start_mg": 20, + "min_mg": 20, + "max_mg": 80, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 20, + "max_mg": 40, + "off_label": true, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы", + "Головная боль" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Необычные кровотечения или синяки" + ], + "monitoring": [], + "withdrawal_risk": "low", + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "citalopram", + "name_ru": "Циталопрам", + "group": "Антидепрессант", + "class": "СИОЗС", + "indications": { + "depression": { + "start_mg": 20, + "min_mg": 20, + "max_mg": 40, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы", + "Головная боль" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Необычные кровотечения или синяки" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "escitalopram", + "name_ru": "Эсциталопрам", + "group": "Антидепрессант", + "class": "СИОЗС", + "indications": { + "depression": { + "start_mg": 10, + "min_mg": 10, + "max_mg": 20, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы", + "Головная боль" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Необычные кровотечения или синяки" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "venlafaxine", + "name_ru": "Венлафаксин", + "group": "Антидепрессант", + "class": "СИОЗСН", + "indications": { + "depression": { + "start_mg": 75, + "min_mg": 75, + "max_mg": 375, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 75, + "max_mg": 225, + "off_label": true, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство", + "Сексуальные проблемы" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [ + "Артериальное давление" + ], + "withdrawal_risk": "high", + "overdose_danger": false, + "max_course_days": null, + "note": "Депрессия: >225 мг/сут — в стационарных условиях" + }, + { + "id": "duloxetine", + "name_ru": "Дулоксетин", + "group": "Антидепрессант", + "class": "СИОЗСН", + "indications": { + "depression": { + "start_mg": 60, + "min_mg": 60, + "max_mg": 120, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "amitriptyline", + "name_ru": "Амитриптилин", + "group": "Антидепрессант", + "class": "ТЦА", + "indications": { + "depression": { + "start_mg": 50, + "min_mg": 150, + "max_mg": 300, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 75, + "max_mg": 150, + "off_label": true, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сухость во рту или запоры", + "Сонливость днём", + "Головокружение при вставании", + "Прибавка веса" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [ + "Артериальное давление и ЭКГ" + ], + "withdrawal_risk": "high", + "overdose_danger": true, + "max_course_days": null, + "note": "Депрессия: старт 50–75 мг; >150 мг/сут — в стационарных условиях" + }, + { + "id": "imipramine", + "name_ru": "Имипрамин", + "group": "Антидепрессант", + "class": "ТЦА", + "indications": { + "depression": { + "start_mg": 25, + "min_mg": 150, + "max_mg": 300, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 75, + "max_mg": 250, + "off_label": true, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сухость во рту или запоры", + "Бессонница или беспокойство", + "Головокружение при вставании", + "Прибавка веса" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [ + "Артериальное давление и ЭКГ" + ], + "withdrawal_risk": "high", + "overdose_danger": true, + "max_course_days": null, + "note": "Депрессия: старт 25–75 мг; >200 мг/сут — в стационарных условиях" + }, + { + "id": "clomipramine", + "name_ru": "Кломипрамин", + "group": "Антидепрессант", + "class": "ТЦА", + "indications": { + "depression": { + "start_mg": 50, + "min_mg": 100, + "max_mg": 250, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Сухость во рту или запоры", + "Сексуальные проблемы", + "Головокружение при вставании", + "Прибавка веса" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [ + "Артериальное давление и ЭКГ" + ], + "withdrawal_risk": "high", + "overdose_danger": true, + "max_course_days": null, + "note": "Старт 50–75 мг; летальность при передозировке средняя" + }, + { + "id": "pipofezine", + "name_ru": "Пипофезин", + "group": "Антидепрессант", + "class": "ТЦА (АТХ — другие антидепрессанты)", + "indications": { + "depression": { + "start_mg": 25, + "min_mg": 150, + "max_mg": 500, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 25–50 мг" + }, + { + "id": "mianserin", + "name_ru": "Миансерин", + "group": "Антидепрессант", + "class": "НаССА", + "indications": { + "depression": { + "start_mg": 30, + "min_mg": 60, + "max_mg": 90, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Сонливость днём" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [ + "Формула крови (по КР — изменения редко)" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "mirtazapine", + "name_ru": "Миртазапин", + "group": "Антидепрессант", + "class": "НаССА", + "indications": { + "depression": { + "start_mg": 15, + "min_mg": 15, + "max_mg": 45, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + }, + "ptsd": { + "start_mg": null, + "min_mg": 30, + "max_mg": 60, + "off_label": true, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём", + "Прибавка веса" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "Старт 15–30 мг" + }, + { + "id": "trazodone", + "name_ru": "Тразодон", + "group": "Антидепрессант", + "class": "Специфический серотонинергический АД", + "indications": { + "depression": { + "start_mg": 100, + "min_mg": 150, + "max_mg": 600, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Сонливость днём", + "Сексуальные проблемы" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Длительная болезненная эрекция" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": ">450 мг/сут — в стационарных условиях" + }, + { + "id": "pirlindole", + "name_ru": "Пирлиндол", + "group": "Антидепрессант", + "class": "Ингибитор МАО-А", + "indications": { + "depression": { + "start_mg": 50, + "min_mg": 150, + "max_mg": 400, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 50–75 мг" + }, + { + "id": "agomelatine", + "name_ru": "Агомелатин", + "group": "Антидепрессант", + "class": "Мелатонинергический АД", + "indications": { + "depression": { + "start_mg": 25, + "min_mg": 25, + "max_mg": 50, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма", + "Желтушность кожи или глаз" + ], + "monitoring": [ + "Билирубин, АСТ, АЛТ, щелочная фосфатаза — в начале лечения и при повышении дозы" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "vortioxetine", + "name_ru": "Вортиоксетин", + "group": "Антидепрессант", + "class": "Серотонинергический модулятор", + "indications": { + "depression": { + "start_mg": 10, + "min_mg": 10, + "max_mg": 20, + "off_label": false, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": "КР депрессия 2024, прил. А3 табл. 2; разд. 3.1" + } + }, + "diary_items": [ + "Тошнота", + "Бессонница или беспокойство" + ], + "diary_items_source": "КР депрессия 2024, прил. А3 табл. 3 (++/+++)", + "red_flags": [ + "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "diazepam", + "name_ru": "Диазепам", + "group": "Анксиолитик (транквилизатор)", + "class": "Производное бензодиазепина", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 5, + "max_mg": 15, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём", + "Головокружение", + "Нарушение координации", + "Снижение внимания и памяти" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2 (комментарии о производных бензодиазепина)", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": "high", + "overdose_danger": false, + "max_course_days": 28, + "note": "Курс не более 4 недель, в тяжёлых случаях до 8–12 недель" + }, + { + "id": "lorazepam", + "name_ru": "Лоразепам", + "group": "Анксиолитик (транквилизатор)", + "class": "Производное бензодиазепина", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 2, + "max_mg": 10, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём", + "Головокружение", + "Нарушение координации", + "Снижение внимания и памяти" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2 (комментарии о производных бензодиазепина)", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": "high", + "overdose_danger": false, + "max_course_days": 21, + "note": "Дозы — из текста разд. 3.2 (в табл. А3 не приведён)" + }, + { + "id": "clonazepam", + "name_ru": "Клоназепам", + "group": "Анксиолитик (транквилизатор)", + "class": "Производное бензодиазепина", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 1, + "max_mg": 4, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём", + "Головокружение", + "Нарушение координации", + "Снижение внимания и памяти" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2 (комментарии о производных бензодиазепина)", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": "high", + "overdose_danger": false, + "max_course_days": 21, + "note": "Дозы — из текста разд. 3.2 (в табл. А3 не приведён)" + }, + { + "id": "alprazolam", + "name_ru": "Алпразолам", + "group": "Анксиолитик (транквилизатор)", + "class": "Производное бензодиазепина", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 0.25, + "max_mg": 4.5, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём", + "Головокружение", + "Нарушение координации", + "Снижение внимания и памяти" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2 (комментарии о производных бензодиазепина)", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": "high", + "overdose_danger": false, + "max_course_days": 90, + "note": "Курс до 3 месяцев; отмена в течение 2–6 недель" + }, + { + "id": "tofisopam", + "name_ru": "Тофизопам", + "group": "Анксиолитик (транквилизатор)", + "class": "Производное бензодиазепина (дневной транквилизатор)", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 50, + "max_mg": 300, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": 84, + "note": "По КР не вызывает физической зависимости и седации; курс 4–12 недель" + }, + { + "id": "etifoxine", + "name_ru": "Этифоксин", + "group": "Анксиолитик", + "class": "Анксиолитик", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 150, + "max_mg": 200, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём", + "Кожная сыпь или зуд" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [ + "Желтушность кожи или глаз", + "Маточное кровотечение" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": 42, + "note": "Курс от нескольких дней до 4–6 недель" + }, + { + "id": "hydroxyzine", + "name_ru": "Гидроксизин", + "group": "Анксиолитик", + "class": "Анксиолитик", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 25, + "max_mg": 100, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Сонливость днём" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": 56, + "note": "Курс обычно 4–8 недель" + }, + { + "id": "buspirone", + "name_ru": "Буспирон", + "group": "Анксиолитик", + "class": "Анксиолитик", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 20, + "max_mg": 30, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "Эффект с 7–14 дня, максимум к 4-й неделе" + }, + { + "id": "alimemazine", + "name_ru": "Алимемазин", + "group": "Антипсихотик", + "class": "Антипсихотическое средство", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 15, + "max_mg": 80, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [ + "Вес и признаки метаболического синдрома" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "По КР побочные эффекты редки и слабо выражены" + }, + { + "id": "risperidone", + "name_ru": "Рисперидон", + "group": "Антипсихотик", + "class": "Антипсихотическое средство", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 0.5, + "max_mg": 8, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Беспокойство, невозможность усидеть на месте", + "Скованность или дрожь в теле", + "Прибавка веса" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2: акатизия, экстрапирамидные расстройства, метаболический синдром", + "red_flags": [], + "monitoring": [ + "Вес и признаки метаболического синдрома" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "quetiapine", + "name_ru": "Кветиапин", + "group": "Антипсихотик", + "class": "Антипсихотическое средство", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 50, + "max_mg": 300, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Беспокойство, невозможность усидеть на месте", + "Скованность или дрожь в теле", + "Прибавка веса" + ], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2: акатизия, экстрапирамидные расстройства, метаболический синдром", + "red_flags": [], + "monitoring": [ + "Вес и признаки метаболического синдрома" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "olanzapine", + "name_ru": "Оланзапин", + "group": "Антипсихотик", + "class": "Антипсихотическое средство", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 5, + "max_mg": 20, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [ + "Прибавка веса", + "Сонливость днём" + ], + "diary_items_source": "КР депрессия 2024, разд. 3.3 (увеличение веса, седация); КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [ + "Вес и признаки метаболического синдрома" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "tiapride", + "name_ru": "Тиаприд", + "group": "Антипсихотик", + "class": "Антипсихотическое средство", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 200, + "max_mg": 300, + "off_label": false, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [ + "Вес и признаки метаболического синдрома" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + }, + { + "id": "chlorprothixene", + "name_ru": "Хлорпротиксен", + "group": "Антипсихотик", + "class": "Антипсихотическое средство", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 25, + "max_mg": 150, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [ + "Вес и признаки метаболического синдрома" + ], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "Расхождение в КР ПТСР: в тексте 15–100 мг, в табл. А3 — 25–150 мг" + }, + { + "id": "lamotrigine", + "name_ru": "Ламотриджин", + "group": "Противоэпилептический препарат", + "class": "Противоэпилептический препарат", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 25, + "max_mg": 500, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [ + "Кожная сыпь" + ], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null, + "note": "Частые кожные высыпания вплоть до токсического эпидермального некролиза" + }, + { + "id": "topiramate", + "name_ru": "Топирамат", + "group": "Противоэпилептический препарат", + "class": "Противоэпилептический препарат", + "indications": { + "ptsd": { + "start_mg": null, + "min_mg": 200, + "max_mg": 400, + "off_label": true, + "expected_response": null, + "course": null, + "source": "КР ПТСР 2023 (ID 753), разд. 3.2; прил. А3" + } + }, + "diary_items": [], + "diary_items_source": "КР ПТСР 2023 (ID 753), разд. 3.2", + "red_flags": [], + "monitoring": [], + "withdrawal_risk": null, + "overdose_danger": false, + "max_course_days": null + } + ], + "texts": { + "expect_effect_depression": "Лекарства от депрессии действуют не сразу. Первые изменения иногда заметны уже через 1–2 недели, а эффект врач оценивает через 3–4 недели. Принимайте препарат по схеме, даже если пока не чувствуете разницы.", + "expect_effect_ptsd": "Эффект препарата при ПТСР развивается постепенно — его оценивают через 4–6 недель. В первую неделю тревога может временно усилиться: это известная реакция, сообщите о ней врачу. Не прекращайте приём самостоятельно.", + "dont_stop_alone": "Не прекращайте приём самостоятельно: резкая отмена может ухудшить самочувствие, а лечение — потерять эффект. Если препарат мешает жить или вы в нём сомневаетесь, обсудите это с врачом — он может изменить дозу или подобрать другой.", + "why_continue_depression": "Даже когда становится лучше, лечение продолжают ещё несколько месяцев в той же дозе: так ниже риск, что депрессия вернётся.", + "withdrawal_missed": "Вы пропустили приём два дня подряд. При резком прекращении этого препарата бывают неприятные ощущения: головокружение, раздражительность, нарушения сна, состояние как при гриппе. Вернитесь к обычной схеме и сообщите врачу, если стало хуже.", + "ran_out": "Лекарство закончилось? Не откладывайте — попросите у врача новый рецепт: перерыв в приёме может ухудшить самочувствие.", + "red_flag": "Этот симптом нужно обсудить с врачом сегодня. Если состояние тяжёлое — вызывайте скорую: 103 или 112.", + "side_effects_general": "Спасибо, что сообщили. Побочные эффекты часто ослабевают в первые недели. Если они мешают жить — это повод обсудить с врачом дозу или замену препарата. Не меняйте схему сами.", + "instruction_fallback": "Уточните у врача или в инструкции к препарату." + }, + "rules": [ + { + "id": "M1", + "params": { + "missed_days_in_row": 2 + }, + "status": "черновик" + }, + { + "id": "M2", + "params": {}, + "status": "черновик" + }, + { + "id": "M3", + "params": { + "ran_out_days_in_row": 2 + }, + "status": "черновик" + }, + { + "id": "M4", + "params": { + "default_max_course_days": 21 + }, + "status": "КР ПТСР 3.2" + }, + { + "id": "M5", + "params": { + "check_day": 28, + "baseline_lookback_days": 14, + "phq9_min_drop_pct": 50, + "pcl5_min_drop": 10 + }, + "status": "черновик" + }, + { + "id": "M6", + "params": {}, + "status": "КР депрессия 3.1.3" + }, + { + "id": "M7", + "params": {}, + "status": "черновик" + }, + { + "id": "M8", + "params": { + "fibser_burden_min": 5, + "side_effect_misses_in_7_days": 2 + }, + "status": "черновик" + }, + { + "id": "M9", + "params": {}, + "status": "в сводке врачу" + }, + { + "id": "M10", + "params": {}, + "status": "приложения А3 обеих КР" + } + ] +} diff --git a/scripts/build_meds_reference.py b/scripts/build_meds_reference.py new file mode 100644 index 0000000..c096104 --- /dev/null +++ b/scripts/build_meds_reference.py @@ -0,0 +1,542 @@ +"""Builds data/meds/meds_reference.json — the drug reference of the medication module. + +Source of every value: the two Minzdrav clinical guidelines in config/rag/corpus/ — + * «Депрессивный эпизод, рекуррентное депрессивное расстройство» (2024): прил. А3 табл. 2 (дозы), + табл. 3 (профили побочных эффектов), разд. 3.1–3.6, прил. Б5, В; + * «Посттравматическое стрессовое расстройство» (2023, ID 753): разд. 3.2, прил. А3 (дозы). +Patient side-effect items: effects rated «++»/«+++» in табл. 3 (antidepressants) or named in the КР text +(other drugs), worded for a patient. Nothing here is a recommendation: the app only follows the doctor's +prescription (module spec, «Границы»). Texts and draft parameters must be reviewed by a psychiatrist. + +Run: uv run python -m scripts.build_meds_reference +""" + +import json +from pathlib import Path + +OUT = Path(__file__).resolve().parents[1] / "data" / "meds" / "meds_reference.json" + +DEP = "КР депрессия 2024" +PTSD = "КР ПТСР 2023 (ID 753)" + +# Patient wording of the КР табл. 3 columns (and of effects named in the КР text). +SE = { + "anticholinergic": "Сухость во рту или запоры", + "nausea": "Тошнота", + "sedation": "Сонливость днём", + "insomnia": "Бессонница или беспокойство", + "sexual": "Сексуальные проблемы", + "orthostatic": "Головокружение при вставании", + "weight": "Прибавка веса", + "headache": "Головная боль", + "dizziness": "Головокружение", + "coordination": "Нарушение координации", + "memory": "Снижение внимания и памяти", + "akathisia": "Беспокойство, невозможность усидеть на месте", + "eps": "Скованность или дрожь в теле", + "skin": "Кожная сыпь или зуд", +} + +RF_BLEEDING = "Необычные кровотечения или синяки" +RF_EARLY = "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма" +RF_JAUNDICE = "Желтушность кожи или глаз" +RF_RASH = "Кожная сыпь" +RF_PRIAPISM = "Длительная болезненная эрекция" +RF_UTERINE = "Маточное кровотечение" + +TCA_MON = "Артериальное давление и ЭКГ" +AP_MON = "Вес и признаки метаболического синдрома" + + +def ad(id_, name, cls, start, lo, hi, items, *, ptsd=None, red=(), mon=(), withdrawal=None, overdose=False, note=None): + """Antidepressant: doses from КР депрессия табл. 2, side effects from табл. 3.""" + d = { + "id": id_, + "name_ru": name, + "group": "Антидепрессант", + "class": cls, + "indications": { + "depression": { + "start_mg": start, + "min_mg": lo, + "max_mg": hi, + "off_label": False, + "expected_response": "3–4 недели", + "course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев", + "source": f"{DEP}, прил. А3 табл. 2; разд. 3.1", + } + }, + "diary_items": [SE[i] for i in items], + "diary_items_source": f"{DEP}, прил. А3 табл. 3 (++/+++)", + "red_flags": [RF_EARLY, *red], + "monitoring": list(mon), + "withdrawal_risk": withdrawal, + "overdose_danger": overdose, + "max_course_days": None, + } + if ptsd: + d["indications"]["ptsd"] = ptsd + if note: + d["note"] = note + return d + + +def ptsd_ind(lo, hi, off_label, start=None): + return { + "start_mg": start, + "min_mg": lo, + "max_mg": hi, + "off_label": off_label, + "expected_response": "4–6 недель", + "course": "Обычно 6–12 месяцев после стабилизации", + "source": f"{PTSD}, разд. 3.2; прил. А3", + } + + +def other( + id_, name, cls, lo, hi, off_label, items, *, red=(), mon=(), withdrawal=None, course_days=None, src, note=None +): + """Non-antidepressant used for PTSD: doses from КР ПТСР прил. А3 / разд. 3.2, effects named in the text.""" + ind = ptsd_ind(lo, hi, off_label) + ind["expected_response"] = None + ind["course"] = None + d = { + "id": id_, + "name_ru": name, + "group": next(g for key, g in GROUPS.items() if key in cls), + "class": cls, + "indications": {"ptsd": ind}, + "diary_items": [SE[i] for i in items], + "diary_items_source": src, + "red_flags": list(red), + "monitoring": list(mon), + "withdrawal_risk": withdrawal, + "overdose_danger": False, + "max_course_days": course_days, + } + if note: + d["note"] = note + return d + + +# Patient-facing drug group by the КР class wording. +GROUPS = { + "бензодиазепин": "Анксиолитик (транквилизатор)", + "Анксиолитик": "Анксиолитик", + "Антипсихотическое": "Антипсихотик", + "Противоэпилептический": "Противоэпилептический препарат", +} + +SSRI = ["nausea", "insomnia", "sexual", "headache"] # табл. 3 + головная боль из разд. 3.1 (СИОЗС) +BENZO = ["sedation", "dizziness", "coordination", "memory"] # КР ПТСР 3.2: седация, головокружение, координация… +BENZO_SRC = f"{PTSD}, разд. 3.2 (комментарии о производных бензодиазепина)" +AP_SRC = f"{PTSD}, разд. 3.2: акатизия, экстрапирамидные расстройства, метаболический синдром" + +DRUGS = [ + ad( + "paroxetine", + "Пароксетин", + "СИОЗС", + 20, + 20, + 50, + SSRI, + ptsd=ptsd_ind(20, 50, False), + red=[RF_BLEEDING], + withdrawal="high", + ), + ad("sertraline", "Сертралин", "СИОЗС", 50, 50, 200, SSRI, ptsd=ptsd_ind(25, 200, False), red=[RF_BLEEDING]), + ad( + "fluvoxamine", + "Флувоксамин", + "СИОЗС", + 50, + 100, + 300, + SSRI, + red=[RF_BLEEDING], + note="Стартовая доза по табл. 2: 50–100 мг", + ), + ad( + "fluoxetine", + "Флуоксетин", + "СИОЗС", + 20, + 20, + 80, + SSRI, + ptsd=ptsd_ind(20, 40, True), + red=[RF_BLEEDING], + withdrawal="low", + ), + ad("citalopram", "Циталопрам", "СИОЗС", 20, 20, 40, SSRI, red=[RF_BLEEDING]), + ad("escitalopram", "Эсциталопрам", "СИОЗС", 10, 10, 20, SSRI, red=[RF_BLEEDING]), + ad( + "venlafaxine", + "Венлафаксин", + "СИОЗСН", + 75, + 75, + 375, + ["nausea", "insomnia", "sexual"], + ptsd=ptsd_ind(75, 225, True), + mon=["Артериальное давление"], + withdrawal="high", + note="Депрессия: >225 мг/сут — в стационарных условиях", + ), + ad("duloxetine", "Дулоксетин", "СИОЗСН", 60, 60, 120, ["nausea", "insomnia"]), + ad( + "amitriptyline", + "Амитриптилин", + "ТЦА", + 50, + 150, + 300, + ["anticholinergic", "sedation", "orthostatic", "weight"], + ptsd=ptsd_ind(75, 150, True), + mon=[TCA_MON], + withdrawal="high", + overdose=True, + note="Депрессия: старт 50–75 мг; >150 мг/сут — в стационарных условиях", + ), + ad( + "imipramine", + "Имипрамин", + "ТЦА", + 25, + 150, + 300, + ["anticholinergic", "insomnia", "orthostatic", "weight"], + ptsd=ptsd_ind(75, 250, True), + mon=[TCA_MON], + withdrawal="high", + overdose=True, + note="Депрессия: старт 25–75 мг; >200 мг/сут — в стационарных условиях", + ), + ad( + "clomipramine", + "Кломипрамин", + "ТЦА", + 50, + 100, + 250, + ["anticholinergic", "sexual", "orthostatic", "weight"], + mon=[TCA_MON], + withdrawal="high", + overdose=True, + note="Старт 50–75 мг; летальность при передозировке средняя", + ), + ad( + "pipofezine", + "Пипофезин", + "ТЦА (АТХ — другие антидепрессанты)", + 25, + 150, + 500, + [], + note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 25–50 мг", + ), + ad("mianserin", "Миансерин", "НаССА", 30, 60, 90, ["sedation"], mon=["Формула крови (по КР — изменения редко)"]), + ad( + "mirtazapine", + "Миртазапин", + "НаССА", + 15, + 15, + 45, + ["sedation", "weight"], + ptsd=ptsd_ind(30, 60, True), + note="Старт 15–30 мг", + ), + ad( + "trazodone", + "Тразодон", + "Специфический серотонинергический АД", + 100, + 150, + 600, + ["sedation", "sexual"], + red=[RF_PRIAPISM], + note=">450 мг/сут — в стационарных условиях", + ), + ad( + "pirlindole", + "Пирлиндол", + "Ингибитор МАО-А", + 50, + 150, + 400, + [], + note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 50–75 мг", + ), + ad( + "agomelatine", + "Агомелатин", + "Мелатонинергический АД", + 25, + 25, + 50, + [], + red=[RF_JAUNDICE], + mon=["Билирубин, АСТ, АЛТ, щелочная фосфатаза — в начале лечения и при повышении дозы"], + ), + ad("vortioxetine", "Вортиоксетин", "Серотонинергический модулятор", 10, 10, 20, ["nausea", "insomnia"]), + # КР ПТСР: анксиолитики, антипсихотические средства, противоэпилептические препараты + other( + "diazepam", + "Диазепам", + "Производное бензодиазепина", + 5, + 15, + False, + BENZO, + withdrawal="high", + course_days=28, + src=BENZO_SRC, + note="Курс не более 4 недель, в тяжёлых случаях до 8–12 недель", + ), + other( + "lorazepam", + "Лоразепам", + "Производное бензодиазепина", + 2, + 10, + False, + BENZO, + withdrawal="high", + course_days=21, + src=BENZO_SRC, + note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)", + ), + other( + "clonazepam", + "Клоназепам", + "Производное бензодиазепина", + 1, + 4, + False, + BENZO, + withdrawal="high", + course_days=21, + src=BENZO_SRC, + note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)", + ), + other( + "alprazolam", + "Алпразолам", + "Производное бензодиазепина", + 0.25, + 4.5, + False, + BENZO, + withdrawal="high", + course_days=90, + src=BENZO_SRC, + note="Курс до 3 месяцев; отмена в течение 2–6 недель", + ), + other( + "tofisopam", + "Тофизопам", + "Производное бензодиазепина (дневной транквилизатор)", + 50, + 300, + False, + [], + course_days=84, + src=f"{PTSD}, разд. 3.2", + note="По КР не вызывает физической зависимости и седации; курс 4–12 недель", + ), + other( + "etifoxine", + "Этифоксин", + "Анксиолитик", + 150, + 200, + False, + ["sedation", "skin"], + red=[RF_JAUNDICE, RF_UTERINE], + course_days=42, + src=f"{PTSD}, разд. 3.2", + note="Курс от нескольких дней до 4–6 недель", + ), + other( + "hydroxyzine", + "Гидроксизин", + "Анксиолитик", + 25, + 100, + True, + ["sedation"], + course_days=56, + src=f"{PTSD}, разд. 3.2", + note="Курс обычно 4–8 недель", + ), + other( + "buspirone", + "Буспирон", + "Анксиолитик", + 20, + 30, + False, + [], + src=f"{PTSD}, разд. 3.2", + note="Эффект с 7–14 дня, максимум к 4-й неделе", + ), + other( + "alimemazine", + "Алимемазин", + "Антипсихотическое средство", + 15, + 80, + False, + [], + mon=[AP_MON], + src=f"{PTSD}, разд. 3.2", + note="По КР побочные эффекты редки и слабо выражены", + ), + other( + "risperidone", + "Рисперидон", + "Антипсихотическое средство", + 0.5, + 8, + True, + ["akathisia", "eps", "weight"], + mon=[AP_MON], + src=AP_SRC, + ), + other( + "quetiapine", + "Кветиапин", + "Антипсихотическое средство", + 50, + 300, + True, + ["akathisia", "eps", "weight"], + mon=[AP_MON], + src=AP_SRC, + ), + other( + "olanzapine", + "Оланзапин", + "Антипсихотическое средство", + 5, + 20, + True, + ["weight", "sedation"], + mon=[AP_MON], + src=f"{DEP}, разд. 3.3 (увеличение веса, седация); {PTSD}, разд. 3.2", + ), + other( + "tiapride", "Тиаприд", "Антипсихотическое средство", 200, 300, False, [], mon=[AP_MON], src=f"{PTSD}, разд. 3.2" + ), + other( + "chlorprothixene", + "Хлорпротиксен", + "Антипсихотическое средство", + 25, + 150, + True, + [], + mon=[AP_MON], + src=f"{PTSD}, разд. 3.2", + note="Расхождение в КР ПТСР: в тексте 15–100 мг, в табл. А3 — 25–150 мг", + ), + other( + "lamotrigine", + "Ламотриджин", + "Противоэпилептический препарат", + 25, + 500, + True, + [], + red=[RF_RASH], + src=f"{PTSD}, разд. 3.2", + note="Частые кожные высыпания вплоть до токсического эпидермального некролиза", + ), + other("topiramate", "Топирамат", "Противоэпилептический препарат", 200, 400, True, [], src=f"{PTSD}, разд. 3.2"), +] + +TEXTS = { + "expect_effect_depression": ( + "Лекарства от депрессии действуют не сразу. Первые изменения иногда заметны уже через 1–2 недели, " + "а эффект врач оценивает через 3–4 недели. Принимайте препарат по схеме, даже если пока не чувствуете разницы." + ), + "expect_effect_ptsd": ( + "Эффект препарата при ПТСР развивается постепенно — его оценивают через 4–6 недель. В первую неделю тревога " + "может временно усилиться: это известная реакция, сообщите о ней врачу. Не прекращайте приём самостоятельно." + ), + "dont_stop_alone": ( + "Не прекращайте приём самостоятельно: резкая отмена может ухудшить самочувствие, а лечение — потерять эффект. " + "Если препарат мешает жить или вы в нём сомневаетесь, обсудите это с врачом — он может изменить дозу " + "или подобрать другой." + ), + "why_continue_depression": ( + "Даже когда становится лучше, лечение продолжают ещё несколько месяцев в той же дозе: так ниже риск, " + "что депрессия вернётся." + ), + "withdrawal_missed": ( + "Вы пропустили приём два дня подряд. При резком прекращении этого препарата бывают неприятные ощущения: " + "головокружение, раздражительность, нарушения сна, состояние как при гриппе. Вернитесь к обычной схеме " + "и сообщите врачу, если стало хуже." + ), + "ran_out": ( + "Лекарство закончилось? Не откладывайте — попросите у врача новый рецепт: " + "перерыв в приёме может ухудшить самочувствие." + ), + "red_flag": "Этот симптом нужно обсудить с врачом сегодня. Если состояние тяжёлое — вызывайте скорую: 103 или 112.", + "side_effects_general": ( + "Спасибо, что сообщили. Побочные эффекты часто ослабевают в первые недели. Если они мешают жить — это повод " + "обсудить с врачом дозу или замену препарата. Не меняйте схему сами." + ), + "instruction_fallback": "Уточните у врача или в инструкции к препарату.", +} + +RULES = [ + {"id": "M1", "params": {"missed_days_in_row": 2}, "status": "черновик"}, + {"id": "M2", "params": {}, "status": "черновик"}, + {"id": "M3", "params": {"ran_out_days_in_row": 2}, "status": "черновик"}, + {"id": "M4", "params": {"default_max_course_days": 21}, "status": "КР ПТСР 3.2"}, + { + "id": "M5", + "params": {"check_day": 28, "baseline_lookback_days": 14, "phq9_min_drop_pct": 50, "pcl5_min_drop": 10}, + "status": "черновик", + }, + {"id": "M6", "params": {}, "status": "КР депрессия 3.1.3"}, + {"id": "M7", "params": {}, "status": "черновик"}, + {"id": "M8", "params": {"fibser_burden_min": 5, "side_effect_misses_in_7_days": 2}, "status": "черновик"}, + {"id": "M9", "params": {}, "status": "в сводке врачу"}, + {"id": "M10", "params": {}, "status": "приложения А3 обеих КР"}, +] + + +def main() -> None: + OUT.parent.mkdir(parents=True, exist_ok=True) + doc = { + "version": "draft-2026-10-04", + "status": "черновик: собран по КР Минздрава, тексты и параметры вычитывает психиатр", + "sources": [ + { + "id": "kr_depression_2024", + "title": "Депрессивный эпизод, рекуррентное депрессивное расстройство", + "year": 2024, + "file": "docs/source/rag/kr_depression.pdf", + }, + { + "id": "kr_ptsd_2023", + "title": "Посттравматическое стрессовое расстройство", + "year": 2023, + "kr_id": 753, + "file": "docs/source/rag/kr_ptsd.pdf", + }, + ], + "drugs": DRUGS, + "texts": TEXTS, + "rules": RULES, + } + OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"{OUT}: {len(DRUGS)} drugs") + + +if __name__ == "__main__": + main() diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index 68db13e..269dfa3 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -117,3 +117,72 @@ async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: async def test_crisis_resources_come_from_config(client: AsyncClient) -> None: phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()] assert "112" in phones + + +async def test_today_links_prescription_to_reference_and_therapy_day(client: AsyncClient) -> None: + today = clock.now().date() + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", modules=["veteran"]) + s.add(Prescription(token=TOKEN, drug="Сертралин", dose="50 мг", schedule={}, start_date=today - timedelta(13))) + rx = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["prescriptions"][0] + assert rx["ref_id"] == "sertraline" and rx["indication"] == "ptsd" and rx["therapy_day"] == 14 + + +async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None: + persona = fixtures.DEMO_PERSONAS[0].token + await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona}) + rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0] + assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1 + + +async def test_questionnaires_are_off_without_release_or_demo_flag( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("INTAKE_DEMO", raising=False) + 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} + + +async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INTAKE_DEMO", "1") + persona = fixtures.DEMO_PERSONAS[0].token + await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona}) + + 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/{persona}/events", json=event)).json()["replies"] + return [b["data"] for r in replies for row in r["buttons"] for b in row] + + 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) + 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" + + +async def test_instrument_catalog_describes_intake_items(client: AsyncClient) -> None: + body = (await client.get("/api/v1/instruments/catalog")).json() + 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["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"]) + + +async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClient) -> None: + body = (await client.get("/api/v1/meds/reference")).json() + assert "rules" not in body and body["texts"]["dont_stop_alone"] + drugs = {d["id"]: d for d in body["drugs"]} + sertraline = drugs["sertraline"] + assert sertraline["indications"]["depression"]["max_mg"] == 200 + assert ( + sertraline["indications"]["ptsd"]["min_mg"] == 25 and "КР ПТСР" in sertraline["indications"]["ptsd"]["source"] + ) + 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()) diff --git a/web/README.md b/web/README.md index d235068..11961d5 100644 --- a/web/README.md +++ b/web/README.md @@ -44,6 +44,23 @@ src/screens/ экраны по docs/mock: вход A1–A3, фокус Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю, следующий визит, время по демо-часам). +### Анкеты, дневник и модуль «Лекарства» + +- **Анкеты этапа 1** (C-SSRS, PHQ-2/PHQ-9, фильтр травмы, TSQ, PCL-5, RUS-AUDIT-S/RUS-AUDIT) и **дневник этапа 2** — + тот же фокус-экран. Пункт распознаётся по кнопкам `iq:…`; чип раздела, «Вопрос n из N» (для адаптивных форм — + без «из N»), период, «Продолжить позже», «Мне нужна помощь сейчас», пропуск необязательных пунктов, ввод часов сна, + шкалы 0–10 с подписями концов, таблица стандартных порций для п. 2 RUS-AUDIT (из утверждённого бланка, без + калькулятора). Метаданные — `GET /api/v1/instruments/catalog`. +- Анкеты включаются только в демо: `INTAKE_DEMO=1` (вместе с `DEMO_CLOCK`). Клинический замок в + `config/intake_runtime.yaml` не трогается. Пока нет подписанной политики маршрутизации и формы C-SSRS «с последнего + контакта», завершённый опрос и дневник уходят на оценку специалиста — UI показывает это отдельным экраном. +- **Карточка препарата** `/meds/:ref` — справочник `data/meds/meds_reference.json` (собирается + `scripts/build_meds_reference.py` по КР Минздрава: депрессия 2024, ПТСР 2023): когда ждать эффекта (с днём лечения), + сколько принимать, что может беспокоить, когда срочно к врачу, «не прекращать самостоятельно». Дозы не показываются. + Черновик — вычитывает психиатр. `GET /api/v1/meds/reference`. +- «Сообщить о побочном эффекте» доступно всегда (`med:side_effects:<доза или дата>`), карточка «День N лечения» на + «Сегодня» — в 1-й, 7-й и 14-й день. + Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API). diff --git a/web/src/api/client.ts b/web/src/api/client.ts index aeaeb2a..6f796d6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,13 @@ -import type { AuthResult, CrisisResource, DemoPersona, DialogEvent, Reply, TodayPlan } from "./types"; +import type { + AuthResult, + Catalog, + CrisisResource, + DemoPersona, + DialogEvent, + MedsReference, + Reply, + TodayPlan, +} from "./types"; // Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build. const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1"; @@ -35,6 +44,8 @@ export const api = { signInAnonymous: () => post("/auth/anonymous"), demoPersonas: () => request("/demo/personas"), crisisResources: () => request("/crisis/resources"), + catalog: () => request("/instruments/catalog"), + medsReference: () => request("/meds/reference"), today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`), sendEvent: async (token: string, event: DialogEvent) => (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1786f60..b0dd747 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -50,11 +50,23 @@ export interface TodayDose { status: DoseStatus; } +export type Indication = "depression" | "ptsd"; + export interface TodayPrescription { drug: string; dose: string; times: string[]; week: { day: string; status: "taken" | "missed" | "pending" | "none" }[]; + ref_id: string | null; + indication: Indication | null; + start_date: string | null; + therapy_day: number | null; +} + +export interface Questionnaires { + enabled: boolean; + intake: "none" | "active" | "paused" | "complete" | "review"; + diary_done: boolean; } export interface TodayPlan { @@ -65,4 +77,74 @@ export interface TodayPlan { next_visit: string | null; doses: TodayDose[]; prescriptions: TodayPrescription[]; + questionnaires: Questionnaires; +} + +// app/core/instrument_catalog.py +export type Section = "safety" | "mood" | "trauma" | "alcohol" | "about" | "diary" | "scale"; + +export interface FormInfo { + id: string; + title: string; + section: Section; + recall_period: string | null; + instruction: string | null; +} + +export interface ItemInfo { + form: string; + index: number; + total: number; + adaptive: boolean; + optional: boolean; + numeric: boolean; +} + +export interface Catalog { + forms: Record; + items: Record; + standard_drinks: { title: string; header: string[]; rows: [string, string[]][]; note: string } | null; +} + +// data/meds/meds_reference.json (draft from the Minzdrav guidelines) +export interface DrugIndication { + off_label: boolean; + expected_response: string | null; + course: string | null; + source: string; +} + +export interface DrugRef { + id: string; + name_ru: string; + group: string; + class: string; + indications: Partial>; + diary_items: string[]; + diary_items_source: string; + red_flags: string[]; + monitoring: string[]; + withdrawal_risk: "high" | "low" | null; + overdose_danger: boolean; + max_course_days: number | null; + note?: string; +} + +export interface MedsReference { + version: string; + status: string; + sources: { id: string; title: string; year: number }[]; + drugs: DrugRef[]; + texts: Record< + | "expect_effect_depression" + | "expect_effect_ptsd" + | "dont_stop_alone" + | "why_continue_depression" + | "withdrawal_missed" + | "ran_out" + | "red_flag" + | "side_effects_general" + | "instruction_fallback", + string + >; } diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx index 8ecd5d1..c323363 100644 --- a/web/src/components/Icon.tsx +++ b/web/src/components/Icon.tsx @@ -121,6 +121,19 @@ const paths = { ), message: , plus: , + clipboard: ( + <> + + + + ), + book: ( + <> + + + + ), + pause: , } as const; export type IconName = keyof typeof paths; diff --git a/web/src/content/reference.ts b/web/src/content/reference.ts new file mode 100644 index 0000000..c49c405 --- /dev/null +++ b/web/src/content/reference.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; + +import { api } from "../api/client"; +import type { Catalog, MedsReference } from "../api/types"; + +/* + * Static reference data from the server (questionnaire catalog, drug reference): fetched once per app run and + * shared by every screen. A failed fetch is retried on the next mount; screens work without it, only less rich. + */ + +function cached(load: () => Promise) { + let value: T | null = null; + let inflight: Promise | null = null; + const get = () => { + inflight ??= load().then( + (v) => (value = v), + (e) => { + inflight = null; + throw e; + }, + ); + return inflight; + }; + return function useCached(): T | null { + const [v, setV] = useState(value); + useEffect(() => { + if (value) return; + let alive = true; + get() + .then((x) => alive && setV(x)) + .catch(() => undefined); + return () => { + alive = false; + }; + }, []); + return v; + }; +} + +export const useCatalog = cached(api.catalog); +export const useMedsReference = cached(api.medsReference); diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index fb22275..ab28858 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -104,6 +104,20 @@ export const ru = { noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.", newMessage: "Новое сообщение", open: "Открыть", + continue: "Продолжить", + surveyStep: (section: string, n: number) => `${section} · вопрос ${n}`, + intakeStart: "Первичный опрос", + intakeStartHint: "15–20 минут, можно прерваться и продолжить позже", + intakeResume: "Продолжить первичный опрос", + intakeResumeHint: "Ответы сохранены, можно продолжить", + intakeReview: + "Опрос приостановлен: продолжить можно после оценки специалиста, врач уже получил уведомление. Если нужна помощь сейчас — 112.", + intakeDone: "Первичный опрос пройден", + diary: "Дневник самочувствия", + diaryHint: "2–3 минуты: настроение, сон, лекарства", + diaryDone: "Дневник заполнен", + therapyDay: (day: number) => `День ${day} лечения`, + effectHint: "Когда ждать эффекта и что важно знать", }, meds: { title: "Моя схема приёма", @@ -119,6 +133,32 @@ export const ru = { taken: "Принял(а)", skip: "Пропущу", sideEffects: "Есть побочные эффекты", + about: "О препарате", + reportSideEffect: "Сообщить о побочном эффекте", + therapyDay: (day: number) => `День лечения: ${day}`, + card: { + back: "К схеме приёма", + prescribed: "Назначено", + effect: "Когда ждать эффекта", + effectAt: (when: string) => `Эффект оценивают через ${when}`, + progress: (day: number) => `Вы принимаете препарат ${day} ${day % 10 === 1 && day % 100 !== 11 ? "день" : [2, 3, 4].includes(day % 10) && ![12, 13, 14].includes(day % 100) ? "дня" : "дней"}`, + course: "Сколько принимать", + courseShort: (days: number) => `Обычно не дольше ${Math.round(days / 7)} недель — срок определяет врач`, + sideEffects: "Что может беспокоить", + sideEffectsHint: + "Побочные эффекты часто ослабевают в первые недели. Если они мешают жить, сообщите врачу — он может изменить дозу или подобрать другой препарат. Не меняйте схему сами.", + sideEffectsNone: "Частые побочные эффекты в клинических рекомендациях не описаны — спросите врача.", + redFlags: "Когда срочно к врачу", + important: "Важно", + withdrawal: "Резкая отмена этого препарата может вызвать неприятные ощущения: головокружение, раздражительность, нарушения сна. Отменяют его постепенно и только с врачом.", + overdose: "Храните препарат в недоступном для других месте и принимайте строго в назначенной дозе.", + monitoring: "Врач может назначить обследования", + instruction: "Как принимать, что делать при пропуске, алкоголь и вождение", + ask: "Задать вопрос о лекарстве", + source: (sources: string) => `Источник: ${sources}. Черновик справочника, проверяется врачом-психиатром.`, + offLabel: "Назначение вне зарегистрированных показаний — так бывает, решение принимает врач.", + unknown: "Справки по этому препарату пока нет. Уточните у врача или в инструкции к препарату.", + }, }, doctor: { title: "Врач", @@ -163,6 +203,25 @@ export const ru = { exit: "К главной", planTitle: "Мой план безопасности", consentTitle: "Согласие на обработку данных о здоровье", + sections: { + safety: "Безопасность", + mood: "Настроение", + trauma: "Пережитый опыт", + alcohol: "Алкоголь", + about: "О вас", + diary: "Дневник", + scale: "Опросник", + }, + questionN: (n: number) => `Вопрос ${n}`, + recall: (period: string) => `Период: ${period}`, + asOf: (date: string) => `Считайте от ${date}`, + saved: "Ответы сохраняются после каждого вопроса", + hoursLabel: "Сколько часов", + hoursUnit: "ч", + hoursHint: "От 0 до 24, можно с половиной: 6,5", + drinks: "Таблица стандартных порций", + drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.", + noticeTitle: "Опрос приостановлен", }, messages: { title: "Сообщения", diff --git a/web/src/dialog/step.ts b/web/src/dialog/step.ts index 8afbff3..f808031 100644 --- a/web/src/dialog/step.ts +++ b/web/src/dialog/step.ts @@ -12,8 +12,27 @@ export type Step = | { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] } | { kind: "number"; lead: string[]; title: string; options: DialogButton[] } | { kind: "actions"; lead: string[]; title: string; options: DialogButton[] } - | { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress } - | { kind: "text"; lead: string[]; title: string; skip?: DialogButton }; + | { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress; formId?: string } + | { kind: "text"; lead: string[]; title: string; skip?: DialogButton } + | Survey + // A questionnaire stopped: paused by the patient (resume button) or held for a specialist (help button only). + | { kind: "notice"; text: string[]; primary?: DialogButton; help?: DialogButton }; + +/** One item of a versioned questionnaire (app/dialog/flows/intake.py → question_reply). */ +export interface Survey { + kind: "survey"; + itemId: string; + instruction: string[]; // form instruction / recall period above the question + title: string; // the item text, verbatim + footnotes: string[]; // «*…» explanations printed under the item on the paper form + options: DialogButton[]; + input: "choice" | "number" | "hours" | "text"; + anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item + asOf?: string; // ISO date the recall period counts from + skip?: DialogButton; + help?: DialogButton; + pause?: DialogButton; +} export interface Progress { n: number; @@ -24,6 +43,12 @@ export interface Progress { const MENU_PROMPT = "Чем могу помочь?"; // config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}" const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/; +// config/texts/intake.yaml → as_of: "Период отсчитывается от даты начала этого опроса: {day}." +const AS_OF = /^Период отсчитывается[^:]*:\s*(\d{4}-\d{2}-\d{2})\.?$/; +// config/texts/intake.yaml → standard_drinks: printed inside the RUS-AUDIT item 2 instruction; shown as a table. +const DRINKS_START = "Стандартная порция"; +const ANCHORS = /^\d+\s*—/; +const IQ = /^iq:[^:]+:([^:]+):(\d+|skip)$/; const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:")); const flat = (r: Reply) => r.buttons.flat(); @@ -44,6 +69,48 @@ function titled(texts: string[]): { lead: string[]; title: string; progress?: Pr return { lead: parts, title: last }; } +function survey(batch: Reply[], qIndex: number): Survey | null { + const buttons = flat(batch[qIndex]); + const iq = buttons.map((b) => IQ.exec(b.data)).find(Boolean); + const help = buttons.find((b) => b.data === "intake:help"); + const pause = buttons.find((b) => b.data === "intake:pause"); + if (!pause || !help) return null; + // A free-text / numeric item has no answer buttons: the item id is then unknown, the text is still verbatim. + const itemId = iq?.[1] ?? ""; + const options = buttons.filter((b) => IQ.test(b.data) && !b.data.endsWith(":skip")); + const skip = buttons.find((b) => b.data.startsWith("iq:") && b.data.endsWith(":skip")); + + let asOf: string | undefined; + const parts = paragraphs(batch[qIndex].text).filter((p) => { + const m = AS_OF.exec(p); + if (m) asOf = m[1]; + return !m; + }); + const footnotes = parts.filter((p) => p.startsWith("*")); + const body = parts.filter((p) => !p.startsWith("*")); + let title = body.pop() ?? ""; + let anchors: string[] | undefined; + if (ANCHORS.test(title) && body.length) { + anchors = title.replace(/\.$/, "").split(/,\s*(?=\d+\s*—)/); + title = body.pop() ?? ""; + } + const instruction = body + .map((p) => (p.includes(DRINKS_START) ? p.slice(0, p.indexOf(DRINKS_START)).trim() : p)) + .filter(Boolean); + // Replies before the question in the same batch (e.g. «invalid answer» note) stay visible above it. + const before = batch.slice(0, qIndex).map((r) => r.text.trim()).filter(Boolean); + + const input = + options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim())) + ? "number" + : options.length + ? "choice" + : skip + ? "text" + : "hours"; + return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause }; +} + export function classify(batch: Reply[]): Step | null { if (batch.length === 0) return null; @@ -79,6 +146,12 @@ export function classify(batch: Reply[]): Step | null { return { kind: "text", lead, title }; } const options = flat(batch[qIndex]); + const s = survey(batch, qIndex); + if (s) return s; + const resume = options.find((b) => b.data === "intake:resume" || b.data === "intake:start"); + const helpOnly = options.length === 1 && options[0].data === "intake:help"; + if ((resume && options.length === 1) || helpOnly) + return { kind: "notice", text: texts.filter(Boolean), primary: resume, help: helpOnly ? options[0] : undefined }; const head = titled(texts.slice(0, qIndex + 1)); const tail = texts.slice(qIndex + 1); // rare: info after the question const lead = [...head.lead, ...tail]; @@ -90,7 +163,8 @@ export function classify(batch: Reply[]): Step | null { return { kind: "number", lead, title: head.title, options }; if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data))) return { kind: "actions", lead, title: head.title, options }; - return { kind: "choice", lead, title: head.title, options, progress: head.progress }; + const formId = /^scale:([^:]+):/.exec(options[0]?.data ?? "")?.[1]; + return { kind: "choice", lead, title: head.title, options, progress: head.progress, formId }; } /** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */ diff --git a/web/src/main.tsx b/web/src/main.tsx index 20f895f..7fac6f9 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -13,6 +13,7 @@ import { HelpProvider } from "./components/HelpSheet"; import { DialogProvider } from "./dialog/DialogContext"; import { AnonymousSignIn } from "./screens/AnonymousSignIn"; import { Doctor } from "./screens/Doctor"; +import { DrugInfo } from "./screens/DrugInfo"; import { EmiasSignIn } from "./screens/EmiasSignIn"; import { Flow } from "./screens/Flow"; import { DoseReminder, Meds } from "./screens/Meds"; @@ -39,6 +40,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/screens/DrugInfo.tsx b/web/src/screens/DrugInfo.tsx new file mode 100644 index 0000000..f62b929 --- /dev/null +++ b/web/src/screens/DrugInfo.tsx @@ -0,0 +1,225 @@ +import { useNavigate, useParams } from "react-router-dom"; + +import type { DrugRef, Indication, MedsReference } from "../api/types"; +import { AppShell, Notice, TopBar } from "../components/AppShell"; +import { Icon, type IconName } from "../components/Icon"; +import { useMedsReference } from "../content/reference"; +import { ru } from "../content/ru"; +import { useDialog } from "../dialog/DialogContext"; +import { sendSideEffect } from "./Meds"; +import { useToday } from "./useToday"; + +/* + * E6 · О препарате (spec «Модуль Лекарства»: карточка препарата). Patient information from the drug reference + * built on the Minzdrav guidelines: when to expect the effect, how long, what may bother, when to see a doctor + * urgently. No doses or ranges are shown — the prescription comes from the doctor only. + */ + +const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +/** «3–4 недели» → the upper bound in days, for the progress bar (null when the text has no weeks). */ +function weeksToDays(text: string | null | undefined): number | null { + const m = /(\d+)(?:\s*[–-]\s*(\d+))?\s*недел/.exec(text ?? ""); + return m ? Number(m[2] ?? m[1]) * 7 : null; +} + +function Section({ icon, tone, title, children }: { icon: IconName; tone?: string; title: string; children: React.ReactNode }) { + return ( +
+

+ + + + {title} +

+ {children} +
+ ); +} + +function EffectTimeline({ day, horizon, when }: { day: number; horizon: number; when: string }) { + const pct = Math.min(100, (day / horizon) * 100); + return ( +
+
+ + +
+
+ {ru.meds.card.progress(day)} + {ru.meds.card.effectAt(when)} +
+
+ ); +} + +function Card({ + drug, + reference: ref, + indication, + day, + prescribed, +}: { + drug: DrugRef; + reference: MedsReference; + indication: Indication | null; + day: number | null; + prescribed?: string; +}) { + const c = ru.meds.card; + const ind = indication ? drug.indications[indication] : Object.values(drug.indications)[0]; + const expect = indication === "ptsd" ? ref.texts.expect_effect_ptsd : ref.texts.expect_effect_depression; + const horizon = weeksToDays(ind?.expected_response); + const sources = ind?.source ?? ""; + + return ( +
+
+ + + +
+

{drug.name_ru}

+

+ {drug.group} + {drug.group !== drug.class && `, ${drug.class}`} +

+ {prescribed && ( +

+ + {c.prescribed}: {prescribed} +

+ )} +
+
+ +
+
+ {ind?.expected_response && ( +
+ {day !== null && horizon && } +

{expect}

+
+ )} + + {(ind?.course || drug.max_course_days) && ( +
+ {ind?.course &&

{ind.course}.

} + {drug.max_course_days &&

{c.courseShort(drug.max_course_days)}.

} + {indication === "depression" &&

{ref.texts.why_continue_depression}

} +
+ )} + +
+ {drug.diary_items.length ? ( +
    + {drug.diary_items.map((s) => ( +
  • {s}
  • + ))} +
+ ) : ( +

{c.sideEffectsNone}

+ )} +

{c.sideEffectsHint}

+
+
+ +
+ {drug.red_flags.length > 0 && ( +
+

+ + + + {c.redFlags} +

+
    + {drug.red_flags.map((s) => ( +
  • {s}
  • + ))} +
+

{ref.texts.red_flag}

+
+ )} + +
+

{ref.texts.dont_stop_alone}

+ {drug.withdrawal_risk === "high" &&

{c.withdrawal}

} + {drug.overdose_danger &&

{c.overdose}

} + {ind?.off_label &&

{c.offLabel}

} + {drug.monitoring.length > 0 && ( + <> +

{c.monitoring}:

+
    + {drug.monitoring.map((s) => ( +
  • {s}
  • + ))} +
+ + )} +
+ +
+

{ref.texts.instruction_fallback}

+
+
+
+ +

{c.source(sources)}

+
+ ); +} + +export function DrugInfo() { + const c = ru.meds.card; + const { ref: refId } = useParams(); + const navigate = useNavigate(); + const dialog = useDialog(); + const { plan } = useToday(); + const ref = useMedsReference(); + const drug = ref?.drugs.find((d) => d.id === refId); + const rx = plan?.prescriptions.find((p) => p.ref_id === refId); + + const ask = () => + void dialog.send({ type: "button", button: "menu:med_question" }, c.ask).then((ok) => ok && navigate("/flow")); + + return ( + +
+ navigate("/meds")} /> + {!ref ? ( +
+ ) : !drug ? ( + {c.unknown} + ) : ( + + )} + {rx && ( +
+ + {plan?.has_doctor && ( + + )} +
+ )} +
+ + ); +} diff --git a/web/src/screens/Flow.tsx b/web/src/screens/Flow.tsx index e304fa3..b26b45c 100644 --- a/web/src/screens/Flow.tsx +++ b/web/src/screens/Flow.tsx @@ -1,19 +1,32 @@ -import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react"; +import { Fragment, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; -import type { DialogButton } from "../api/types"; +import type { Catalog, DialogButton, Section } from "../api/types"; import { AppShell, Notice, TopBar } from "../components/AppShell"; import { HotlineCard, telHref, useHelp } from "../components/HelpSheet"; 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 } from "../dialog/step"; +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), - * onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3). + * onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3), + * 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 }) { @@ -60,7 +73,14 @@ function Body({ texts }: { texts: string[] }) { ); } -function Progress({ n, total }: { n: number; total: number }) { +function Progress({ n, total, adaptive = false }: { n: number; total: number; adaptive?: boolean }) { + // Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar. + if (adaptive) + return ( +
+ {ru.flow.questionN(n)} +
+ ); return (
@@ -71,6 +91,56 @@ function Progress({ n, total }: { n: number; total: number }) { ); } +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 ( +
+

{ru.flow.drinks}

+

{ru.flow.drinksLead}

+
+ + + + + {table.header.map((h) => ( + + ))} + + + + {table.rows.map(([label, cells]) => ( + + + {cells.map((c, i) => ( + + ))} + + ))} + +
СП + {h} +
{label.replace(/\s*СП$/, "")}{c}
+
+

+ {table.title} {table.note} +

+
+ ); +} + function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) { return (
@@ -131,10 +201,12 @@ function NumberScale({ options, busy, onSubmit, + anchors, }: { options: DialogButton[]; busy: boolean; onSubmit: (b: DialogButton) => void; + anchors?: string[]; }) { const [picked, setPicked] = useState(null); return ( @@ -143,7 +215,7 @@ function NumberScale({ {picked ? picked.label : "–"} -
+
{options.map((b) => (
+ {anchors && anchors.length === 2 && ( +
+ {anchors[0]} + {anchors[1]} +
+ )}
+ + ); +} + +function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) { + const item = catalog?.items[step.itemId]; + const form = item ? catalog?.forms[item.form] : undefined; + return ( + <> +
+ {form && } + {step.instruction.length > 0 && ( +
+ +
+ )} +

{step.title}

+ {step.footnotes.map((f) => ( +

+ {f} +

+ ))} + {step.asOf && ( +

+ + {ru.flow.asOf(dayMonth(step.asOf))} +

+ )} +
+ {step.itemId === "rus_audit_2" && catalog?.standard_drinks && } + + ); +} + +function SurveyAct({ + step, + stepKey, + busy, + press, + say, +}: { + step: Survey; + stepKey: string; + busy: boolean; + press: (b: DialogButton) => void; + say: (text: string) => void; +}) { + let answer: ReactNode; + switch (step.input) { + case "number": + answer = ; + break; + case "hours": + answer = ; + break; + case "text": + answer = ( + undefined} /> + ); + break; + default: + answer = ( + <> + + {step.skip && ( + + )} + + ); + } + return ( + <> + {answer} +
+ {step.pause && ( + + )} + {step.help && ( + + )} +
+

{ru.flow.saved}

+ + ); +} + function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) { const style = (b: DialogButton) => b.data.startsWith("med:taken") @@ -284,6 +488,7 @@ export function Flow() { const navigate = useNavigate(); const dialog = useDialog(); const { resources } = useHelp(); + const catalog = useCatalog(); const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]); const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]); const home = () => navigate("/today"); @@ -318,10 +523,40 @@ export function Flow() { ); } else if (step.kind === "idle") { content = ; - } else { - const top = ( - {step.kind === "choice" && step.progress && } + } else if (step.kind === "notice") { + content = ( +
+ +

{ru.flow.noticeTitle}

+
+ +
+ {step.primary && ( + + )} + {step.help && ( + + )} + +
); + } else { + const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined; + const progress = + step.kind === "choice" && step.progress ? ( + + ) : item ? ( + + ) : null; + 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; @@ -433,15 +668,26 @@ export function Flow() { ); break; - case "choice": - lead = ; + case "survey": + lead = ; + act = ; + break; + case "choice": { + const form = step.formId ? catalog?.forms[step.formId] : undefined; + lead = ( + <> + {form && } + + + ); act = ; break; + } } content = (
{top} -
+
{lead}
{act} diff --git a/web/src/screens/Meds.tsx b/web/src/screens/Meds.tsx index fa6a5f4..8446b51 100644 --- a/web/src/screens/Meds.tsx +++ b/web/src/screens/Meds.tsx @@ -1,6 +1,6 @@ import { useNavigate, useParams } from "react-router-dom"; -import type { TodayPrescription } from "../api/types"; +import type { TodayPlan, TodayPrescription } from "../api/types"; import { AppShell, Notice, TopBar } from "../components/AppShell"; import { Icon, type IconName } from "../components/Icon"; @@ -30,6 +30,24 @@ export function WeekDots({ week }: { week: TodayPrescription["week"] }) { ); } +/** + * «Сообщить о побочном эффекте» — always available (spec «Модуль Лекарства»). Same engine step as the reminder's + * «Есть побочные эффекты»: `med:side_effects:`, where ref is today's dose of the drug or, without a dose row, + * today's ISO date (app/dialog/flows/meds.py). + */ +export function sendSideEffect( + dialog: ReturnType, + navigate: (to: string) => void, + plan: TodayPlan | null, + drug: string, + label: string, +) { + if (!plan) return; + const dose = plan.doses.find((d) => d.drug === drug && d.status === "pending") ?? plan.doses.find((d) => d.drug === drug); + const ref = dose ? String(dose.id) : plan.today; + void dialog.send({ type: "button", button: `med:side_effects:${ref}` }, label).then((ok) => ok && navigate("/flow")); +} + // E4 · Моя схема приёма export function Meds() { const t = ru.meds; @@ -39,6 +57,7 @@ export function Meds() { const ask = () => void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow")); + const reportSideEffect = (drug: string) => sendSideEffect(dialog, navigate, plan, drug, t.reportSideEffect); return ( @@ -74,6 +93,23 @@ export function Meds() { ))} +
+ {p.ref_id && ( + + )} + +
))}
diff --git a/web/src/screens/Today.tsx b/web/src/screens/Today.tsx index 809f4f9..c4c06f1 100644 --- a/web/src/screens/Today.tsx +++ b/web/src/screens/Today.tsx @@ -2,14 +2,50 @@ import { useNavigate } from "react-router-dom"; import type { TodayDose, TodayPrescription } from "../api/types"; import { AppShell, Notice } from "../components/AppShell"; -import { Icon } from "../components/Icon"; +import { Icon, type IconName } from "../components/Icon"; +import { useCatalog } from "../content/reference"; 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"; +// Spec «Модуль Лекарства»: the expected-effect card is pushed on these days of therapy. +const EFFECT_DAYS = [1, 7, 14]; + +function Task({ + icon, + title, + sub, + hero, + chip, + onClick, + disabled, +}: { + icon: IconName; + title: string; + sub: string; + hero?: boolean; + chip?: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) { return ( )} @@ -142,22 +224,36 @@ export function Today() { ))} - {plan && !plan.checkin_done && ( - + onClick={() => + q.intake === "none" ? go("intake:start", t.intakeStart) : go("intake:resume", t.intakeResume) + } + /> + )} + {diaryDue && waiting?.kind !== "survey" && ( + go("menu:checkin", t.diary)} + /> + )} + {plan && !surveys && !plan.checkin_done && ( + go("menu:checkin", t.checkin)} + /> )}
)} @@ -190,7 +286,7 @@ export function Today() { - {t.checkinDone} + {surveys ? t.diaryDone : t.checkinDone}
)}
diff --git a/web/src/screens/useToday.ts b/web/src/screens/useToday.ts index e48b0dd..a968242 100644 --- a/web/src/screens/useToday.ts +++ b/web/src/screens/useToday.ts @@ -40,3 +40,9 @@ export function humanDate(iso: string): string { const wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); return `${d} ${MONTHS[m - 1]}, ${WEEKDAYS[wd]}`; } + +/** "4 октября" — from an ISO date. */ +export function dayMonth(iso: string): string { + const [, m, d] = iso.split("-").map(Number); + return `${d} ${MONTHS[m - 1]}`; +} diff --git a/web/src/theme/global.css b/web/src/theme/global.css index 62b7500..f91aeca 100644 --- a/web/src/theme/global.css +++ b/web/src/theme/global.css @@ -782,7 +782,7 @@ button.row-card:hover:not(:disabled) { .numscale__row { display: grid; - grid-template-columns: repeat(11, 1fr); + grid-template-columns: repeat(var(--n, 11), 1fr); gap: 4px; } @@ -1900,14 +1900,16 @@ button.row-card:hover:not(:disabled) { /* Wide content (consent text, 0–10 scale) reads better in one centred column. */ .split--consent, - .split--number { + .split--number, + .split--in-number { grid-template-columns: minmax(0, 1fr); max-width: 760px; gap: var(--s-6); } .split--consent .split__lead, - .split--number .split__lead { + .split--number .split__lead, + .split--in-number .split__lead { position: static; } @@ -1915,7 +1917,8 @@ button.row-card:hover:not(:disabled) { grid-template-columns: 1fr 1fr; } - .split--number .numscale__row { + .split--number .numscale__row, + .split--in-number .numscale__row { gap: var(--s-2); } @@ -2035,6 +2038,393 @@ button.row-card:hover:not(:disabled) { } } +/* ------------------------------------------------------------------ questionnaires (stage 1 / stage 2) */ + +.formchip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2) var(--s-3); +} + +.formchip__title { + font-size: var(--fs-13); + color: var(--c-text-2); +} + +.chip--sec { + gap: 6px; + padding: 5px var(--s-3) 5px 10px; + background: var(--c-blue-soft); + color: var(--c-blue); +} + +.chip--mood, +.chip--diary { + background: var(--c-teal-soft); + color: var(--c-teal-strong); +} + +.chip--trauma { + background: var(--c-amber-soft); + color: #a86a00; +} + +.chip--alcohol, +.chip--about { + background: var(--c-navy-soft); + color: var(--c-navy); +} + +.survey { + gap: var(--s-3); +} + +.survey__instr { + padding-left: var(--s-3); + border-left: 3px solid var(--c-border-strong); + color: var(--c-text-2); +} + +.survey__instr .body-text { + font-size: var(--fs-16); +} + +.survey__q { + font-size: var(--fs-24); + line-height: 1.3; +} + +.survey__asof { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--fs-13); + color: var(--c-text-2); +} + +.survey__aux { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--s-3); + padding-top: var(--s-2); + border-top: 1px solid var(--c-border); +} + +.survey__saved { + margin: 0; +} + +.btn--sm { + width: auto; + min-height: 40px; + padding: 0 var(--s-3); + font-size: var(--fs-14); +} + +.linkbtn { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + padding: 0 var(--s-1); + border: 0; + background: none; + font-weight: 600; + font-size: var(--fs-14); + text-decoration: underline; + text-underline-offset: 3px; +} + +.linkbtn--danger { + color: var(--c-red-strong); +} + +.btn--ghost-red { + background: transparent; + color: var(--c-red-strong); +} + +.btn--outline-red { + background: var(--c-surface); + border-color: var(--c-red); + color: var(--c-red-strong); +} + +.center-self { + align-self: center; + justify-self: center; +} + +/* RUS-AUDIT item 2: standard-drink table from the approved form */ +.drinks { + display: grid; + gap: var(--s-2); + padding: var(--s-4); + border: 1px solid var(--c-border); + border-radius: var(--r-tile); + background: var(--c-surface); +} + +.drinks__scroll { + overflow-x: auto; + margin: 0 calc(-1 * var(--s-2)); +} + +.drinks table { + width: 100%; + min-width: 420px; + border-collapse: collapse; + font-size: var(--fs-13); + font-variant-numeric: tabular-nums; +} + +.drinks th, +.drinks td { + padding: 8px; + text-align: left; + border-bottom: 1px solid var(--c-border); + vertical-align: top; +} + +.drinks thead th { + color: var(--c-text-2); + font-weight: 600; +} + +.drinks tbody th { + white-space: nowrap; + color: var(--c-navy); +} + +/* diary: hours of sleep */ +.hours { + display: grid; + gap: var(--s-2); +} + +.hours__label { + font-weight: 600; +} + +.hours__field { + display: flex; + align-items: center; + gap: var(--s-2); + width: 180px; + padding: 0 var(--s-4); + border: 1.5px solid var(--c-border-strong); + border-radius: var(--r-btn); + background: var(--c-surface); +} + +.hours__field:focus-within { + border-color: var(--c-focus); + box-shadow: 0 0 0 3px var(--c-blue-soft); +} + +.hours__field input { + flex: 1; + min-width: 0; + height: var(--tap); + border: 0; + outline: none; + background: none; + font-size: var(--fs-28); + font-weight: 700; + color: var(--c-navy); +} + +.hours__field span { + font-size: var(--fs-18); + color: var(--c-text-2); +} + +.hours .btn { + margin-top: var(--s-2); +} + +/* ------------------------------------------------------------------ medication: drug card, therapy day */ + +.rx__foot { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + padding-top: var(--s-3); + border-top: 1px solid var(--c-border); +} + +.effect { + display: flex; + align-items: center; + gap: var(--s-4); + width: 100%; + padding: var(--s-3) var(--s-4); + border: 1px solid #cfe0f5; + border-radius: var(--r-tile); + background: var(--c-blue-soft); + text-align: left; + color: var(--c-text); +} + +.effect > svg:last-child { + flex: none; + color: var(--c-blue); +} + +.effect__day { + flex: none; + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: 14px; + background: var(--c-surface); + color: var(--c-blue); + line-height: 1; +} + +.effect__num { + font-size: var(--fs-24); + font-weight: 700; +} + +.effect__unit { + font-size: var(--fs-12); + color: var(--c-text-2); +} + +.drug { + display: grid; + gap: var(--s-6); +} + +.drug__head { + display: flex; + align-items: center; + gap: var(--s-4); +} + +.drug__head .tile-icon--xl { + width: 72px; + height: 72px; +} + +.drug__rx { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: var(--s-2); + padding: 6px var(--s-3); + border-radius: var(--r-chip); + background: var(--c-teal-soft); + color: var(--c-teal-strong); + font-size: var(--fs-14); + font-weight: 600; +} + +.drug__grid, +.drug__col { + display: grid; + gap: var(--s-4); + align-content: start; +} + +.drug__sec { + display: grid; + gap: var(--s-3); + padding: var(--s-5); + border: 1px solid var(--c-border); + border-radius: var(--r-tile); + background: var(--c-surface); +} + +.drug__sec p { + margin: 0; + line-height: 1.5; +} + +.drug__sec--red { + border-color: var(--c-red-line); + background: var(--c-red-soft); +} + +.drug__h { + display: flex; + align-items: center; + gap: var(--s-3); + margin: 0; + font-size: var(--fs-18); + font-weight: 700; +} + +.drug__sub { + font-weight: 600; +} + +.badge-icon--blue { + background: var(--c-blue); +} + +.timeline { + display: grid; + gap: var(--s-2); +} + +.timeline__bar { + position: relative; + height: 10px; + border-radius: 999px; + background: var(--c-tile); + border: 1px solid var(--c-border); +} + +.timeline__fill { + position: absolute; + inset: 0 auto 0 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--c-teal-soft), var(--c-teal)); +} + +.timeline__mark { + position: absolute; + top: 50%; + width: 18px; + height: 18px; + border: 3px solid var(--c-surface); + border-radius: 50%; + background: var(--c-teal); + box-shadow: 0 1px 3px rgb(15 39 71 / 25%); + transform: translate(-50%, -50%); +} + +.timeline__legend { + display: flex; + justify-content: space-between; + gap: var(--s-3); + font-size: var(--fs-13); + color: var(--c-text-2); +} + +@media (max-width: 1023px) { + .btn-row--stack-phone { + grid-template-columns: 1fr; + } +} + +@media (min-width: 1024px) { + .drug__grid { + grid-template-columns: 1fr 1fr; + gap: var(--s-5); + } + + .survey__q { + font-size: var(--fs-28); + } +} + @media (prefers-reduced-motion: reduce) { *, *::before,