diff --git a/.env.example b/.env.example index f1e04f5..53e7253 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..57139ca --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run inside Linux containers: never check them out with CRLF (bash fails on the carriage return). +*.sh text eol=lf diff --git a/app/api/fixtures.py b/app/api/fixtures.py index 79ea55a..9be6541 100644 --- a/app/api/fixtures.py +++ b/app/api/fixtures.py @@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo from app.api.schemas import ( AlertOut, + DemoPersona, DiscontinuationRisk, DoseOut, PatientCard, @@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow") TODAY = date(2026, 10, 3) DOCTOR_ID = "doc-smirnova" +# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app. +DEMO_PERSONAS: list[DemoPersona] = [ + DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"), +] + def _risk(score: int, factors: list[str]) -> DiscontinuationRisk: level = "high" if score >= 60 else "medium" if score >= 30 else "low" diff --git a/app/api/patient_web.py b/app/api/patient_web.py new file mode 100644 index 0000000..15845c3 --- /dev/null +++ b/app/api/patient_web.py @@ -0,0 +1,406 @@ +"""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. + +Two ways in: +- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The + patient is linked to the attending doctor, who sees the data on the dashboard. +- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine + supports tokens without an identity record (consent is kept in the dialog state), so no personal data is + stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor. + +The token returned here is the session credential of the prototype. Production needs short-lived sessions +issued by the server instead of handing out the pseudonymous token itself. +""" + +import logging +import secrets +from collections import defaultdict +from datetime import date, datetime, time, timedelta +from typing import Any, Literal +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select + +from app.api import fixtures +from app.api.schemas import DemoPersona +from app.core import clock +from app.core.instrument_catalog import Catalog, catalog +from app.core.intake_questions import DIARY_KINDS +from app.core.intake_runtime import intake_enabled, load_runtime +from app.core.interpretation import texts as results_texts +from app.core.meds_reference import find_drug, indication_for, load_reference +from app.core.safety import CrisisResource, crisis_reply +from app.db import repo +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 + +log = logging.getLogger(__name__) + +router = APIRouter(tags=["patient-web"]) +_emias = EmiasMock() + + +class AuthOut(BaseModel): + token: str + mode: Literal["emias", "anonymous"] + display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design + + +class EmiasCodeIn(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + code: str = Field(min_length=1, max_length=CODE_MAX_LEN) + + +class AnonymousIn(BaseModel): + model_config = ConfigDict(extra="forbid") + focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module + + +# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010). +_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"} + + +class EmiasDemoIn(BaseModel): + model_config = ConfigDict(extra="forbid") + persona_token: str = Field(min_length=1, max_length=64) + + +async def _attach_doctor(token: str, doctor_id: str) -> None: + async with get_clinical_session() as s: + if await repo.get_profile(s, token) is None: + await repo.upsert_profile(s, token=token, doctor_id=doctor_id) + + +async def _seed_demo_persona(token: str) -> None: + """Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses. + + Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the + doctor's card tell the same story. + """ + card = fixtures.card(token) + item = next((p for p in fixtures.PATIENTS if p.token == token), None) + if card is None or item is None: + return + now = clock.now() + today = now.date() + async with get_clinical_session() as s: + if await repo.get_profile(s, token) is None: + await repo.upsert_profile( + s, + token=token, + doctor_id=fixtures.DOCTOR_ID, + modules=list(item.modules), + 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=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( + Dose( + token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo) + ) + ) + + +@router.post("/auth/emias", response_model=AuthOut) +async def auth_emias(body: EmiasCodeIn) -> AuthOut: + """Sign-in through ЕМИАС (demo: one-time connect code from the doctor).""" + person = await _emias.verify(body.code) + if person is None: + log.info("web auth: emias code rejected") + raise HTTPException(401, "code not accepted") + await _attach_doctor(person.token, person.doctor_id) + log.info("web auth: emias tok=%s", person.token[:6]) + return AuthOut(token=person.token, mode="emias") + + +@router.get("/patient/demo-personas", response_model=list[DemoPersona]) +async def patient_demo_personas() -> list[DemoPersona]: + """Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`).""" + return fixtures.DEMO_PERSONAS + + +@router.post("/auth/emias/demo", response_model=AuthOut) +async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut: + """Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`).""" + persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None) + if persona is None: + raise HTTPException(404, "persona not found") + await _seed_demo_persona(persona.token) + log.info("web auth: emias demo tok=%s", persona.token[:6]) + return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name) + + +@router.post("/auth/anonymous", response_model=AuthOut) +async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut: + """Anonymous sign-in: a fresh random token; no identity record, no doctor. + + The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds + no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself. + """ + token = secrets.token_hex(TOKEN_LEN // 2) + focus = body.focus if body is not None else None + async with get_clinical_session() as s: + await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else []) + log.info("web auth: anonymous tok=%s", token[:6]) + return AuthOut(token=token, mode="anonymous") + + +class DoseItem(BaseModel): + id: int + drug: str + dose: str + time: str # HH:MM, patient's local time + status: Literal["taken", "missed", "skipped", "pending"] + + +class WeekDay(BaseModel): + day: date + status: Literal["taken", "missed", "pending", "none"] + + +class PrescriptionItem(BaseModel): + drug: str + 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 # both parts of today's diary + diary_next: Literal["morning", "evening"] | None = None # the part still open today + + +class TodayOut(BaseModel): + today: date + now: str # HH:MM on the (demo) clock — the client never uses its own clock + has_doctor: bool + checkin_done: bool + 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"]: + if not statuses: + return "none" + if any(s in ("missed", "skipped") for s in statuses): + return "missed" + return "taken" if all(s == "taken" for s in statuses) else "pending" + + +@router.get("/me/{token}/today", response_model=TodayOut) +async def my_today(token: str) -> TodayOut: + """The patient's own plan for today (home screen) and this week's dose marks (medication screen). + + Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan. + """ + now = clock.now() + today = now.date() + week_start = today - timedelta(days=today.weekday()) + async with get_clinical_session() as s: + profile = await repo.get_profile(s, token) + prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all() + doses = ( + await s.scalars( + select(Dose) + .where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo)) + .order_by(Dose.planned_at) + ) + ).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] = [] + marks: dict[tuple[int, date], list[str]] = defaultdict(list) + for d in doses: + local = d.planned_at.astimezone(now.tzinfo) + marks[(d.prescription_id, local.date())].append(d.status) + rx = by_rx.get(d.prescription_id) + if local.date() == today and rx is not None: + today_doses.append( + 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() + done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"} + open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts] + diary_done = not open_parts + + def item(p: Prescription) -> PrescriptionItem: + ref = find_drug(p.drug) + 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 and bool(profile.doctor_id), + checkin_done=(checkin is not None and checkin.mood is not None) or diary_done, + next_visit=profile.next_visit if profile else None, + doses=today_doses, + prescriptions=[ + 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( + # Stage 1 needs no profile: the screening creates it (anonymous people have none before it). + enabled=intake_enabled(), + intake=_intake_state(records), + diary_done=diary_done, + diary_next=open_parts[0] if open_parts else None, + ), + ) + + +class ResultFinding(BaseModel): + code: str + title: str + + +class Conclusion(BaseModel): + """Preliminary conclusion of stage 1 — a screening result, not a diagnosis.""" + + day: date + title: str + lines: list[str] + findings: list[ResultFinding] + modules: list[str] + disclaimer: str + + +class HistoryItem(BaseModel): + id: str + kind: str + kind_title: str + status: str + status_title: str + day: date + started_at: datetime + completed_at: datetime | None + title: str | None # interpretation shown to the patient when the form was finished + lines: list[str] + + +class ResultsOut(BaseModel): + conclusion: Conclusion | None + history: list[HistoryItem] + + +@router.get("/me/{token}/results", response_model=ResultsOut) +async def my_results(token: str) -> ResultsOut: + """The patient's own questionnaire log: every administration with its date, status and the interpretation that + was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers.""" + async with get_clinical_session() as s: + records = list( + ( + await s.scalars( + select(QuestionnaireRecord) + .where(QuestionnaireRecord.token == token) + .order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id) + ) + ).all() + ) + t = results_texts() + intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None) + conclusion = None + if intake and intake.details.get("interpretation"): + i = intake.details["interpretation"] + conclusion = Conclusion( + day=intake.day, + title=i["title"], + lines=i["lines"], + findings=[ResultFinding(**f) for f in i.get("findings", [])], + modules=i.get("modules", []), + disclaimer=i.get("disclaimer", t["disclaimer"]), + ) + history = [ + HistoryItem( + id=r.id, + kind=r.kind, + kind_title=t["kinds"].get(r.kind, r.kind), + status=r.status, + status_title=t["status"].get(r.status, r.status), + day=r.day, + started_at=r.started_at, + completed_at=r.completed_at, + title=(r.details.get("interpretation") or {}).get("title"), + lines=(r.details.get("interpretation") or {}).get("lines", []), + ) + for r in records + ] + return ResultsOut(conclusion=conclusion, history=history) + + +@router.get("/crisis/resources", response_model=list[CrisisResource]) +async def crisis_resources() -> list[CrisisResource]: + """Help lines for the always-visible «help now» button — the same list as the fixed crisis script.""" + 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/api/routes.py b/app/api/routes.py index 5f70926..8d616b8 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -16,6 +16,7 @@ from sqlalchemy import select from app.api import demo as console from app.api import doctor_views as views from app.api import fixtures +from app.api.patient_web import router as patient_web_router from app.api.questionnaire import router as questionnaire_router from app.api.schemas import ( AckIn, @@ -46,6 +47,7 @@ from app.services.monitoring import refresh_status router = APIRouter(prefix="/api/v1") router.include_router(questionnaire_router) +router.include_router(patient_web_router) DEFAULT_CLINIC = "clinic-demo" CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand diff --git a/app/core/instrument_catalog.py b/app/core/instrument_catalog.py new file mode 100644 index 0000000..7b0a3b5 --- /dev/null +++ b/app/core/instrument_catalog.py @@ -0,0 +1,139 @@ +"""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 + +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 DIARY_PARTS, content, diary_config, 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 = diary_config() + core = [item for part in DIARY_PARTS for item in diary[part]] + depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]] + ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]] + add( + FormInfo(id="diary", title="Дневник самочувствия", section="diary"), + [i["id"] for i in core] + depression + ptsd, + optional={i["id"] for i in core if i.get("optional")}, + numeric={i["id"] for i in core if i.get("numeric")}, + ) + + # Scales of the onboarding / scheduled sessions (`scale::...` buttons): the form header only. + 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_questions.py b/app/core/intake_questions.py index 8c598a2..b332574 100644 --- a/app/core/intake_questions.py +++ b/app/core/intake_questions.py @@ -18,6 +18,7 @@ class Question: period: str = "context" numeric: bool = False optional: bool = False + maximum: float = 24 def content() -> dict: @@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]: def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None: - c = score_cssrs_recent(cssrs_answers(answers)) - if c.missing: - form = load_cssrs_russian() - item = next(i for i in form.items if i.id == c.missing[0]) - period = texts()["periods"][item.recall_period] - # Guidance is interviewer-facing; retained in the source and record version. - return Question( - "cssrs_" + item.id, - period + "\n\n" + item.text, - tuple((o.value, o.label) for o in form.options), - item.recall_period, - ) + """Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, + then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. + Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py), + so a positive answer interrupts the questionnaire wherever it comes.""" for item in ("phq9_1", "phq9_2"): if item not in answers: return scale_question("phq9", item) @@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio for item in load_scale("rus_audit_s").item_ids: if item not in answers: return scale_question("rus_audit_s", item) + c = score_cssrs_recent(cssrs_answers(answers)) + if c.missing: + form = load_cssrs_russian() + item = next(i for i in form.items if i.id == c.missing[0]) + period = texts()["periods"][item.recall_period] + # Guidance is interviewer-facing; retained in the source and record version. + return Question( + "cssrs_" + item.id, + period + "\n\n" + item.text, + tuple((o.value, o.label) for o in form.options), + item.recall_period, + ) if sex is None and "sex" not in answers: return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать"))) for key in ("tbi", "psychotropics"): @@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio return None -def diary_questions( - modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None -) -> tuple[Question, ...]: - config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text()) - selected = ["core"] - detailed = [name for name in ("veteran", "depression") if name in modules] - if len(detailed) == 2: - detailed = [detailed[(day_index if day_index is not None else weekday) % 2]] - if "veteran" in detailed: - selected.append("ptsd") - if "depression" in detailed: - selected.append("depression") - if suicide_monitoring or "depression" in modules: - selected.append("suicide") - questions = [] - for section in selected: - for item in config[section]: - if item.get("weekly") and weekday != 0: - continue - questions.append( - Question( - item["id"], - item["text"], - tuple((o["value"], o["label"]) for o in item.get("options", [])), - "past_week" if item.get("weekly") else "past_day", - item.get("numeric", False), - item.get("optional", False), +def cssrs_since_contact_question(answers: dict) -> Question | None: + """Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact». + + The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the + items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact + is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies. + """ + c = score_cssrs_recent(cssrs_answers(answers)) + missing = [k for k in c.missing if k != "q6_recent"] + if not missing: + return None + form = load_cssrs_russian() + item = next(i for i in form.items if i.id == missing[0]) + return Question( + "cssrs_" + item.id, + texts()["periods"]["since_last_contact"] + "\n\n" + item.text, + tuple((o.value, o.label) for o in form.options), + "since_last_contact", + ) + + +DIARY_PARTS = ("morning", "evening") +DIARY_KINDS = ("diary_morning", "diary_evening") + + +def diary_config() -> dict: + return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8")) + + +def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]: + """Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile. + + PTSD («veteran») and depression run side by side when both are present. The depression module asks three + PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`) + so diary answers never mix with the full scales. + """ + if part not in DIARY_PARTS: + raise ValueError("unknown diary part") + config = diary_config() + questions = [ + Question( + item["id"], + item["text"], + tuple((o["value"], o["label"]) for o in item.get("options", [])), + "past_day", + item.get("numeric", False), + item.get("optional", False), + item.get("max", 24), + ) + for item in config[part] + ] + if part == "evening": + forms = content()["forms"] + if "depression" in modules: + module = config["depression"] + items = forms[module["form"]]["items"] + per_day = module["items_per_day"] + start = (day_index % (len(items) // per_day)) * per_day + options = tuple((o["value"], o["label"]) for o in module["options"]) + for item in items[start : start + per_day]: + number = item["id"].rsplit("_", 1)[1] + questions.append( + Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today") + ) + if "veteran" in modules: + module = config["ptsd"] + form = forms[module["form"]] + by_id = {i["id"]: i["text"] for i in form["items"]} + options = tuple((o["value"], o["label"]) for o in form["options"]) + for item_id in module["items"]: + number = item_id.rsplit("_", 1)[1] + questions.append( + Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today") ) - ) - questions.sort(key=lambda q: q.id != "death_thoughts") return tuple(questions) diff --git a/app/core/intake_runtime.py b/app/core/intake_runtime.py index 63112ed..6ee7ada 100644 --- a/app/core/intake_runtime.py +++ b/app/core/intake_runtime.py @@ -10,7 +10,7 @@ import yaml from pydantic import BaseModel, ConfigDict, Field, model_validator from app.config import get_settings -from app.core.questionnaire import Approval, load_policy +from app.core.questionnaire import Approval, demo_mode, load_policy class RuntimePolicy(BaseModel): @@ -21,13 +21,20 @@ class RuntimePolicy(BaseModel): content_review: Approval | None = None contact_review: Approval | None = None timezone: str - daily_hour: int = Field(ge=0, le=23) - mood_baseline_days: int = Field(ge=2) - mood_bad_days: int = Field(ge=1) - mood_drop: int = Field(ge=1, le=10) + morning_hour: int = Field(ge=0, le=23) + evening_hour: int = Field(ge=0, le=23) + norm_days: int = Field(ge=2) + week_days: int = Field(ge=2) + min_norm_values: int = Field(ge=1) + min_week_values: int = Field(ge=1) + mood_drop: float = Field(gt=0, le=10) + depression_item_rise: float = Field(gt=0, le=3) + ptsd_sum_rise: float = Field(gt=0, le=16) sleep_bad_days: int = Field(ge=1) sleep_hours_below: float = Field(gt=0, le=24) - nightmares_at: int = Field(ge=0, le=4) + alcohol_heavy_portions: float = Field(gt=0) + alcohol_heavy_days: int = Field(ge=1) + adherence_below: float = Field(gt=0, le=1) missed_medication_days: int = Field(ge=1) high_risk_silence_days: int = Field(ge=1) medication_days: tuple[int, ...] @@ -44,6 +51,8 @@ class RuntimePolicy(BaseModel): set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values() ): raise ValueError("all route and scale intervals must be defined") + if self.morning_hour >= self.evening_hour: + raise ValueError("the morning diary must come before the evening one") if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days: raise ValueError("medication days must be nonempty and strictly increasing") if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()): @@ -64,8 +73,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 demo_mode() + + 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: @@ -115,39 +133,73 @@ def due_scales( return tuple(sorted(due)) -def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]: - """Consecutive calendar dates; missing data never become zero or missed doses. +def _mean(values: list[float]) -> float: + return sum(values) / len(values) - Baseline is the seven dates BEFORE the three-day deterioration window; - every baseline value is required. Nightmares OR short sleep must hold on - each of three nights. These candidate definitions require signed review. + +def diary_flags( + history: dict[date, dict], + today: date, + policy: RuntimePolicy | None = None, + *, + medication_active: bool = False, +) -> tuple[str, ...]: + """Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days. + + `history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of + observation; trends are checked only after it. Missing data never become zero or missed doses. """ p = policy or load_runtime() - flags = [] + flags: list[str] = [] current = history.get(today, {}) - if current.get("death_thoughts") == 1: - flags.append("suicide_assessment_now") - recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)] - baseline = [ - history.get(today - timedelta(days=i), {}).get("mood") - for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days) - ] - if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent): - mean = sum(baseline) / len(baseline) - if all(mean - x["mood"] >= p.mood_drop for x in recent): - flags.append("phq9_due_mood") + start = min(history) if history else today + norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days] + week_start = today - timedelta(days=p.week_days - 1) + week = [history[d] for d in sorted(history) if week_start <= d <= today] + trend = (week_start - start).days >= p.norm_days + + def values(rows: list[dict], key) -> list[float]: + return [v for r in rows if (v := key(r)) is not None] + + def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool: + before, now = values(norm, key), values(week, key) + if not trend or len(before) < min_norm or len(now) < min_week: + return False + change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before) + return change >= by + + if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True): + flags.append("phq9_due_mood") + # Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item. + for item in range(1, 10): + if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2): + flags.append("phq9_due_depression") + break + # PTSD module: only the sum of the four items is interpreted, never single clusters. + ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18") + + def ptsd_sum(r): + return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None + + if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values): + flags.append("pcl5_due_ptsd") nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)] if all( - (r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at) - or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) + (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0 for r in nights ): flags.append("sleep_review") + heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions] + if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions: + flags.append("alcohol_on_medication") + if len(heavy) >= p.alcohol_heavy_days: + flags.append("rus_audit_due_alcohol") doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)] - if all(r.get("medication") == "none" for r in doses): + if all(r.get("medication") == "no" for r in doses): flags.append("medication_contact") - if current.get("side_effects") == 1: + taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")] + if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below: + flags.append("adherence_review") + if current.get("medication_reason") == "side_effects": flags.append("side_effects_review") - if current.get("medication") == "partial": - flags.append("partial_medication_review") return tuple(flags) diff --git a/app/core/interpretation.py b/app/core/interpretation.py new file mode 100644 index 0000000..aa46d65 --- /dev/null +++ b/app/core/interpretation.py @@ -0,0 +1,165 @@ +"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml. + +Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2. +Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags. +Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3). +""" + +from typing import Any + +import yaml + +from app.config import get_settings +from app.core.intake_questions import content +from app.core.questionnaire import IntakeDecision, IntakePolicy + +# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile). +FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"} +# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022). +PHQ9_REMISSION_BELOW = 5 +PHQ9_IMPROVED_BY = 5 +PCL5_RESPONSE_BY = 10 +PCL5_IMPROVED_BELOW = 28 + + +def texts() -> dict[str, Any]: + return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")) + + +def render(interpretation: dict[str, Any]) -> str: + """Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any).""" + parts = [interpretation["title"], *interpretation.get("lines", [])] + if interpretation.get("disclaimer"): + parts.append(interpretation["disclaimer"]) + return "\n\n".join(p for p in parts if p) + + +def _alcohol_zone(totals: dict) -> int | None: + full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {} + return full.get("zone") if full.get("complete") else short.get("zone") + + +def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]: + """Screening findings of stage 1, in the order they are shown.""" + found = [] + phq = totals.get("phq9") or {} + if phq.get("complete"): + if phq["total"] >= policy.phq9_specialist_at: + found.append("depression_suspected") + elif phq["total"] >= policy.phq9_psychologist_at: + found.append("depression_mild") + pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {} + if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at: + found.append("ptsd_suspected") + elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at: + found.append("ptsd_signs") + zone = _alcohol_zone(totals) + if zone == 4: + found.append("alcohol_high") + elif zone in (2, 3): + found.append("alcohol_risky") + if (totals.get("cssrs_recent") or {}).get("risk") == "low": + found.append("suicide_low") + if answers.get("tbi") == 1: + found.append("tbi") + return found + + +def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict: + t = texts() + found = intake_findings(totals, answers, policy) + modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE] + head = t["intake"]["attention" if found else "ok"] + lines = [head["text"]] + lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found] + if decision.primary_route in t["routes"]: + lines.append(t["routes"][decision.primary_route]) + names = ", ".join(t["modules"]["names"][m] for m in modules) + lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else "")) + return { + "kind": "intake", + "title": head["title"], + "lines": lines, + "codes": found or ["ok"], + "findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found], + "route": decision.primary_route, + "modules": modules, + "disclaimer": t["disclaimer"], + } + + +def _phq9_level(total: int) -> str: + band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"]) + return band["code"] + + +def scale_interpretation( + kind: str, + totals: dict, + *, + baseline: int | None, + previous: int | None, + policy: IntakePolicy, + retriage_delta: int, +) -> dict: + """A scheduled full scale: level, then the change against the start of observation and the last measurement.""" + t = texts()["scales"] + if kind == "rus_audit_s": + zone = _alcohol_zone(totals) + result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"] + level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"] + line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level) + return { + "kind": kind, + "title": t[kind]["title"], + "lines": [line], + "codes": [f"zone_{zone}" if zone else "unknown"], + } + total = totals[kind]["total"] + pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below" + code = _phq9_level(total) if kind == "phq9" else pcl5_level + lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])] + change = None + if previous is not None and total - previous >= retriage_delta: + change = "worse" + elif baseline is not None: + if kind == "phq9": + if total < PHQ9_REMISSION_BELOW: + change = "remission" + elif baseline > 0 and total <= baseline / 2: + change = "response" + elif baseline - total >= PHQ9_IMPROVED_BY: + change = "improved" + else: + change = "stable" + else: + if baseline - total >= PCL5_RESPONSE_BY: + change = "response" + elif total < PCL5_IMPROVED_BELOW <= baseline: + change = "improved" + else: + change = "stable" + if change: + lines.append(t["change"][change]) + return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]} + + +def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict: + t = texts()["diary"] + shown = [f for f in flags if f in t["flags"]] + return { + "kind": f"diary_{part}", + "title": t["saved"].format(part=t["parts"][part]), + "lines": [t["flags"][f] for f in shown], + "codes": list(flags), + } + + +def cssrs_interpretation(risk: str | None) -> dict: + t = texts()["cssrs"] + return { + "kind": "cssrs_since_contact", + "title": t["low" if risk == "low" else "none"], + "lines": [], + "codes": [risk or "none"], + } diff --git a/app/core/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/app/core/questionnaire.py b/app/core/questionnaire.py index b148bd6..09a3065 100644 --- a/app/core/questionnaire.py +++ b/app/core/questionnaire.py @@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati from __future__ import annotations +import os from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -69,8 +70,17 @@ class IntakePolicy(BaseModel): return self +def demo_mode() -> bool: + """Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data. + + It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the + real policy and the release gate in config/intake_runtime.yaml are never touched. + """ + return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock + + def load_policy(path: Path | None = None) -> IntakePolicy: - path = path or get_settings().config_dir / "questionnaire.yaml" + path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml") return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) diff --git a/app/dialog/flows/core.py b/app/dialog/flows/core.py index 7447731..a69a22c 100644 --- a/app/dialog/flows/core.py +++ b/app/dialog/flows/core.py @@ -70,7 +70,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str: modules = list(profile.modules) if profile is not None and profile.modules else [] from app.core.intake_runtime import intake_enabled - if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")): + # Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous + # person has no profile at all before it. + if after is None and intake_enabled(): return "intake.start" if after in modules: modules = modules[modules.index(after) + 1 :] diff --git a/app/dialog/flows/intake.py b/app/dialog/flows/intake.py index 80c801b..22b2905 100644 --- a/app/dialog/flows/intake.py +++ b/app/dialog/flows/intake.py @@ -4,9 +4,13 @@ import math from dataclasses import asdict, replace from zoneinfo import ZoneInfo +from app.core import interpretation from app.core.intake_questions import ( + DIARY_KINDS, + Question, content, cssrs_answers, + cssrs_since_contact_question, diary_questions, group_answers, next_intake, @@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent from app.core.scales import load_scale from app.core.types import AlertLevel +from app.db import repo from app.db.intake import QuestionnaireRecord, QuestionnaireTask from app.dialog.contracts import Button, Event, Reply from app.dialog.crisis import crisis_open, open_crisis @@ -28,21 +33,23 @@ def allowed(ctx): return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled() +def side_effects_question(): + """FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3).""" + return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week") + + def next_question(row): a = row.answers if row.kind == "intake": return next_intake(a, load_policy(), row.details.get("sex")) - if row.kind == "diary": - for q in diary_questions( - row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday() - ): + if row.kind == "cssrs_since_contact": + return cssrs_since_contact_question(a) + if row.kind in DIARY_KINDS: + part = row.kind.removeprefix("diary_") + for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)): if q.id in a: continue - if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"): - continue - if q.id == "side_effects" and a.get("medication") == "not_prescribed": - continue - if q.id == "thought_intensity" and a.get("death_thoughts") != 1: + if q.id == "medication_reason" and a.get("medication") != "no": continue return q return None @@ -63,11 +70,7 @@ def next_question(row): q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week") return q if row.details.get("medication_active") and "side_effects" not in a: - return next( - q - for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday()) - if q.id == "side_effects" - ) + return side_effects_question() return None @@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None): if profile: profile.enhanced_monitoring = True flags = list(service.decision(row).flags) if row else [] - if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0): + if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0): flags.append("cssrs_since_contact_required") if row: await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags}) @@ -132,7 +135,8 @@ async def advance(ctx, row): a = row.answers c = score_cssrs_recent(cssrs_answers(a)) policy = load_policy() - if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high": + # PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule). + if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high": return await urgent(ctx, row, code="suicide_signal") if c.positive and policy.cssrs_positive_policy == "stop_any": return await urgent(ctx, row, code="cssrs_positive") @@ -153,9 +157,6 @@ async def advance(ctx, row): ctx.now, params={"record_id": row.id}, ) - if row.kind == "cssrs_since_contact": - # Recent and Since Last Contact are different instruments. No silent substitution. - return await manual_review(ctx, row, "cssrs_since_contact_required") try: q = next_question(row) # Validate preserved short answers against a newly answered full-form item 1. @@ -187,6 +188,8 @@ async def advance(ctx, row): extra = {"decision": asdict(result), "totals": service.summary(row)} if a.get("recent_trauma") == 1: extra["recent_trauma"] = True + extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy) + await apply_modules(ctx, extra["interpretation"]["modules"]) if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"): await service.alert( ctx.session, @@ -196,23 +199,29 @@ async def advance(ctx, row): ctx.now, params={"record_id": row.id, **extra}, ) - elif row.kind == "diary": - history = { - r.day: r.answers - for r in await service.records(ctx.session, ctx.token) - if r.kind == "diary" and r.status == "complete" - } - history[row.day] = a - flags = diary_flags(history, row.day) + elif row.kind == "cssrs_since_contact": + extra["totals"] = {"cssrs_recent": asdict(c)} + extra["interpretation"] = interpretation.cssrs_interpretation(c.risk) + elif row.kind in DIARY_KINDS: + # Morning and evening answers of one date form one diary day. + history: dict = {} + for r in await service.records(ctx.session, ctx.token): + if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id: + history.setdefault(r.day, {}).update(r.answers) + history.setdefault(row.day, {}).update(a) + flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False)) extra["flags"] = flags for flag in flags: await service.alert( ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id} ) - if flag == "phq9_due_mood": - await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now) + if flag in DIARY_SCALE_TASKS: + sid = DIARY_SCALE_TASKS[flag] + await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now) + extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags) else: extra["totals"] = service.summary(row) + runtime = load_runtime() if row.kind in ("phq9", "pcl5"): sid = row.kind total = extra["totals"][sid]["total"] @@ -223,27 +232,37 @@ async def advance(ctx, row): if total >= priority else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician") ) - prior = next( - ( - r - for r in reversed(await service.records(ctx.session, ctx.token)) - if r.id != row.id - and r.status == "complete" - and r.version == row.version - and r.details.get("totals", {}).get(sid, {}).get("complete") - and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False)) - ), - None, - ) + earlier = [ + r + for r in await service.records(ctx.session, ctx.token) + if r.id != row.id + and r.status == "complete" + and r.version == row.version + and r.details.get("totals", {}).get(sid, {}).get("complete") + and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False)) + ] + prior = earlier[-1] if earlier else None if prior: change = total - prior.details["totals"][sid]["total"] extra["delta"] = { "baseline_record": prior.id, "value": change, - "retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"), + "retriage": change >= getattr(runtime, sid + "_retriage_delta"), } + extra["interpretation"] = interpretation.scale_interpretation( + sid, + extra["totals"], + baseline=earlier[0].details["totals"][sid]["total"] if earlier else None, + previous=prior.details["totals"][sid]["total"] if prior else None, + policy=policy, + retriage_delta=getattr(runtime, sid + "_retriage_delta"), + ) if row.kind == "rus_audit_s" and not row.details.get("sex"): return await manual_review(ctx, row, "alcohol_sex_unknown") + if row.kind == "rus_audit_s": + extra["interpretation"] = interpretation.scale_interpretation( + "rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0 + ) if extra["totals"]["rus_audit"].get("zone") == 4: extra["parallel_routes"] = ["narcologist"] retriage = extra.get("delta", {}).get("retriage", False) @@ -257,13 +276,48 @@ async def advance(ctx, row): params={"record_id": row.id, **extra}, ) await service.finish(ctx, row, "complete", extra=extra) + shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"] + if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"): + # The risk questions open the evening diary; continue straight into it. + step = await start(ctx, "diary_evening") + step.replies.insert(0, Reply(text=shown, kind="info")) + return step from app.dialog.flows.core import menu_reply - replies = [menu_reply(ctx, texts()["done"])] - alcohol_total = extra.get("totals", {}).get("rus_audit", {}) - if alcohol_total.get("zone") in (2, 3): - replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info")) - return Step(replies=replies, next_state="idle", data={"questionnaire_record": None}) + return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None}) + + +# Diary flags that bring a full scale forward («Этап 2», p.4). +DIARY_SCALE_TASKS = { + "phq9_due_mood": "phq9", + "phq9_due_depression": "phq9", + "pcl5_due_ptsd": "pcl5", + "rus_audit_due_alcohol": "rus_audit_s", +} + + +async def apply_modules(ctx, modules): + """Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules + (diagnosis from ЕМИАС) are kept and only extended.""" + profile = await ctx.profile() + if profile is None: + await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules)) + return + current = list(profile.modules or []) + merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules) + await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged) + + +def local_day(ctx): + return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date() + + +async def done_today(ctx, kind): + day = local_day(ctx) + return any( + r.kind == kind and r.day == day and r.status == "complete" + for r in await service.records(ctx.session, ctx.token) + ) async def start(ctx, kind="intake", task=None): @@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None): if task: return Step() return Step(replies=[Reply(text=texts()["review"], kind="info")]) + # A manual start takes over the scheduled task of the same form, so the task does not stay outstanding. + task = task or await service.outstanding_task(ctx.session, ctx.token, kind) row = await service.create_record(ctx, kind, task=task) return await advance(ctx, row) @@ -316,14 +372,28 @@ async def checkin(ctx, event): existing = await service.records(ctx.session, ctx.token) if not any(r.kind == "intake" and r.status == "complete" for r in existing): return await start(ctx) - day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date() - if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing): - return Step(replies=[Reply(text=texts()["done"], kind="info")]) - risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now) - if risk_task.status in ("pending", "active"): - await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now) - return await start(ctx, "cssrs_since_contact", risk_task) - return await start(ctx, "diary") + return await next_diary(ctx) + + +async def next_diary(ctx): + """The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS + «since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk).""" + day = local_day(ctx) + done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"} + for kind in DIARY_KINDS: + if kind in done: + continue + profile = await ctx.profile() + if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done: + risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now) + return await start(ctx, "cssrs_since_contact", risk_task) + return await start(ctx, kind) + return Step(replies=[Reply(text=texts()["diary_done"], kind="info")]) + + +@trigger("button:diary:next") +async def diary_button(ctx, event): + return await next_diary(ctx) @state("intake") @@ -365,12 +435,16 @@ async def collect(ctx, event: Event): elif event.type == "text" and q.numeric: try: value = float(str(ctx.text).replace(",", ".")) - valid = math.isfinite(value) and 0 <= value <= 24 + valid = math.isfinite(value) and 0 <= value <= q.maximum except ValueError: pass elif event.type == "text" and q.optional: value, valid = str(ctx.text)[:2000], True if not valid: - return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)]) + hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric") + return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)]) service.save_answer(row, q, value, ctx.now) + if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime": + # Behaviour since the last contact is recent by definition (see cssrs_since_contact_question). + service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True) return await advance(ctx, row) diff --git a/app/dialog/flows/meds.py b/app/dialog/flows/meds.py index 47f543b..2904316 100644 --- a/app/dialog/flows/meds.py +++ b/app/dialog/flows/meds.py @@ -47,6 +47,8 @@ from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa QUESTION_STATE = "meds.question" SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text" +# A client sends this button when the person submits a free-text step without writing anything. +NO_COMMENT = "text:skip" SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other") SKIP_REASONS = ("forgot", "side_effects", "quit", "other") @@ -379,7 +381,8 @@ def _side_effect_code(code: str) -> Any: async def handler(ctx: Ctx, event: Event, ref: str) -> Step: if code == "other": return Step( - replies=[Reply(text=load_meds_texts().side_effects.describe)], + # kind="question": the engine waits for free text here (clients show an input, optional). + replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")], next_state=SIDE_EFFECT_TEXT_STATE, data={"med_se_ref": ref}, ) @@ -394,12 +397,15 @@ for _code in SIDE_EFFECT_CODES: @state(SIDE_EFFECT_TEXT_STATE) async def _side_effect_text(ctx: Ctx, event: Event) -> Step: + ref = ctx.data.get("med_se_ref") + if event.type == "button" and event.button == NO_COMMENT: + # The description is optional: «other» side effect without words. + return await _after_side_effects(ctx, ["other"], str(ref) if ref else None) if event.type != "text" or ctx.text is None: return await leave_to_idle(ctx, event, {"med_se_ref": None}) facts = await ctx.gateway.extract_checkin(ctx.text) # Store only normalised codes, never the description itself (it stays in the scrubbed message log). codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"] - ref = ctx.data.get("med_se_ref") return await _after_side_effects(ctx, codes, str(ref) if ref else None) @@ -429,7 +435,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step: t = load_meds_texts().question if await current_prescription(ctx) is None: return Step(replies=[menu_reply(ctx, t.no_prescription)]) - return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE) + return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE) async def answer_question(ctx: Ctx, rx: Rx | None) -> str: diff --git a/app/integrations/emias_mock.py b/app/integrations/emias_mock.py new file mode 100644 index 0000000..c29fcbf --- /dev/null +++ b/app/integrations/emias_mock.py @@ -0,0 +1,35 @@ +"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code). + +Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect +code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The +class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel. + +It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice +(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the +production provider for all channels (#14). + +This module may use the identity DB: it is the registration boundary (guardrail #5). +""" + +from app.core import clock +from app.db import identity_repo +from app.db.identity import ConnectCode +from app.db.session import get_identity_session +from app.integrations.base import VerifiedPerson + +CODE_MAX_LEN = 32 + + +class EmiasMock: + async def verify(self, auth_code: str) -> VerifiedPerson | None: + code = auth_code.strip().upper() + if not code or len(code) > CODE_MAX_LEN: + return None + async with get_identity_session() as s: + row = await s.get(ConnectCode, code) + if row is None: + return None + token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now()) + if token is None: + return None + return VerifiedPerson(token=token, doctor_id=row.doctor_id) diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index 9a72211..e6f50e8 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -15,7 +15,9 @@ from sqlalchemy import select, text from sqlalchemy.exc import SQLAlchemyError from app.core import clock +from app.core.intake_questions import DIARY_KINDS from app.core.intake_runtime import due_scales, intake_enabled, load_runtime +from app.core.questionnaire import load_policy from app.db import repo from app.db.clinical import PatientProfile from app.db.intake import QuestionnaireTask @@ -45,16 +47,18 @@ async def plan(): initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) if initial is None: continue - last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day]) + diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"] + last_activity = max([*diaries, initial.day]) high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis")) if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days: await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now) if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"): continue - if local.hour >= p.daily_hour and not any( - r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows - ): - await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now) + done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"} + # Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2). + for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)): + if local.hour >= hour and kind not in done: + await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now) last = {} for row in rows: if row.status != "complete": @@ -82,12 +86,21 @@ async def plan(): after_crisis=crisis_date, policy=p, ) - if scales or local.hour >= p.daily_hour: + # Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring. + if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done: await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now) + totals = initial.details.get("totals", {}) + modules = set(profile.modules or []) + policy = load_policy() + # PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3). + wanted = { + "phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")), + "pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at, + "rus_audit_s": True, + } for sid in scales: - if sid == "pcl5" and initial.answers.get("trauma") != 1: - continue - await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now) + if wanted[sid]: + await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now) async def deliver(): diff --git a/app/services/questionnaire.py b/app/services/questionnaire.py index 364a637..76a62f6 100644 --- a/app/services/questionnaire.py +++ b/app/services/questionnaire.py @@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None): sex = profile.sex if profile else None if sex is None and prior: sex = prior.details.get("sex") + day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date() row = QuestionnaireRecord( id=uuid4().hex, token=ctx.token, @@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None): version=content()["version"], policy_version=load_policy().version, started_at=ctx.now, - day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(), + day=day, status="active", answers={}, details={ @@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None): "weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")), "answer_metadata": {}, "task_id": task.id if task else None, + # Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day). + "day_index": (day - prior.day).days if prior else 0, }, ) ctx.session.add(row) @@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None): return row -def save_answer(row, question, value, now): +def save_answer(row, question, value, now, *, derived=False): """Anchor recall windows to administration, never to the day of a resumed answer.""" day = row.day starts = { @@ -105,8 +108,8 @@ def save_answer(row, question, value, now): "period": question.period, "period_start": starts[question.period].isoformat() if question.period in starts else None, "period_end": day.isoformat(), - "source": "patient", - "derived": False, + "source": "derived" if derived else "patient", + "derived": derived, }, }, } @@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now): return row +async def outstanding_task(session, token, kind): + return await session.scalar( + select(QuestionnaireTask).where( + QuestionnaireTask.token == token, + QuestionnaireTask.kind == kind, + QuestionnaireTask.status.in_(("pending", "active")), + ) + ) + + async def lock_patient(session, token): # PostgreSQL serializes callbacks from different workers. SQLite tests additionally # use the engine's in-process lock. Consent and answers share this transaction. diff --git a/config/instruments/diary.yaml b/config/instruments/diary.yaml index 5c0d73c..e69e6b6 100644 --- a/config/instruments/diary.yaml +++ b/config/instruments/diary.yaml @@ -1,189 +1,84 @@ -version: diary-v2-2026-10-03 -core: -- id: mood - text: 'Какое у Вас сегодня настроение? +# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3. +# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds +# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only. +# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran». +version: diary-v3-2026-10-04 - 0 — самое плохое, 10 — самое хорошее.' - options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' - - value: 5 - label: '5' - - value: 6 - label: '6' - - value: 7 - label: '7' - - value: 8 - label: '8' - - value: 9 - label: '9' - - value: 10 - label: '10' +# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00. +morning: - id: sleep_hours - text: Сколько часов Вы спали прошлой ночью? + source: Consensus Sleep Diary + text: Сколько часов Вы спали этой ночью? numeric: true -- id: rested - text: 'Насколько Вы выспались? - - 0 — совсем не выспались, 4 — полностью выспались.' + max: 24 +- id: sleep_quality + source: Consensus Sleep Diary + text: Как Вы оцениваете качество своего сна? options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' + - {value: 0, label: Очень плохо} + - {value: 1, label: Плохо} + - {value: 2, label: Удовлетворительно} + - {value: 3, label: Хорошо} + - {value: 4, label: Очень хорошо} +- id: alcohol_portions + source: принцип Timeline Followback; порция по RUS-AUDIT + text: |- + Сколько стандартных порций алкоголя Вы выпили вчера? + + Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0. + numeric: true + max: 50 +evening: +- id: mood + source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9) + text: |- + Как Вы оцениваете своё настроение сегодня? + + 0 — самое плохое, 10 — самое хорошее. + options: + - {value: 0, label: '0'} + - {value: 1, label: '1'} + - {value: 2, label: '2'} + - {value: 3, label: '3'} + - {value: 4, label: '4'} + - {value: 5, label: '5'} + - {value: 6, label: '6'} + - {value: 7, label: '7'} + - {value: 8, label: '8'} + - {value: 9, label: '9'} + - {value: 10, label: '10'} - id: medication - text: Приняли ли Вы сегодня лекарства по схеме? + source: КР ПТСР — дневник приёма лекарств + text: Приняли ли Вы сегодня лекарства, назначенные врачом? options: - - value: full - label: По схеме - - value: partial - label: Частично - - value: none - label: Нет - - value: not_prescribed - label: Не назначены + - {value: 'yes', label: Да} + - {value: 'no', label: Нет} + - {value: not_prescribed, label: Лекарства не назначены} - id: medication_reason - text: Почему не приняли лекарства по схеме? + source: КР ПТСР — дневник приёма лекарств + text: Почему не приняли? options: - - value: forgot - label: Забыл(а) - - value: side_effects - label: Побочные эффекты - - value: declined - label: Не хочу - - value: ran_out - label: Закончились - - value: other - label: Другая причина -- id: side_effects - text: Беспокоили ли Вас сегодня побочные эффекты лекарств? - options: &id001 - - value: 1 - label: Да - - value: 0 - label: Нет -- id: substances - text: Употребляли ли Вы сегодня алкоголь или другие вещества? - options: *id001 -- id: comment - text: Что хотите передать врачу? - optional: true -ptsd: -- id: intrusions - text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?' - options: &id002 - - value: 0 - label: Совсем нет - - value: 1 - label: Немного - - value: 2 - label: Умеренно - - value: 3 - label: Сильно - - value: 4 - label: Крайне сильно -- id: nightmares - text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?' - options: *id002 -- id: avoidance - text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?' - options: *id002 -- id: hypervigilance - text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?' - options: *id002 -- id: irritability - text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?' - options: *id002 + - {value: forgot, label: Забыл(а)} + - {value: side_effects, label: Побочные эффекты} + - {value: declined, label: Решил(а) не принимать} + - {value: ran_out, label: Закончились} + +# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer +# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9. +# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule). depression: -- id: interest - text: 'Насколько Вам сегодня было интересно и приятно что-то делать? - - 0 — совсем нет, 4 — крайне сильно.' - options: *id002 -- id: depressed - text: Насколько Вы сегодня чувствовали подавленность или безнадёжность? - options: *id002 -- id: energy - text: 'Насколько у Вас сегодня были силы и энергия? - - 0 — совсем нет, 4 — крайне сильно.' - options: *id002 -- id: planned_activity - text: Сделали ли Вы сегодня хотя бы одно запланированное дело? - options: *id001 -suicide: -- id: death_thoughts - text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить? - options: *id001 -- id: thoughts_intensity - text: 'Если да — насколько сильными они были? - - 0 — совсем слабые, 10 — максимально сильные.' + form: phq9 + items_per_day: 3 + question: 'Насколько сегодня Вас беспокоило:' options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' - - value: 5 - label: '5' - - value: 6 - label: '6' - - value: 7 - label: '7' - - value: 8 - label: '8' - - value: 9 - label: '9' - - value: 10 - label: '10' -- id: hope - text: 'Насколько Вы верите, что станет лучше? + - {value: 0, label: Совсем нет} + - {value: 1, label: Немного} + - {value: 2, label: Умеренно} + - {value: 3, label: Сильно} - 0 — совсем не верю, 10 — полностью верю.' - options: - - value: 0 - label: '0' - - value: 1 - label: '1' - - value: 2 - label: '2' - - value: 3 - label: '3' - - value: 4 - label: '4' - - value: 5 - label: '5' - - value: 6 - label: '6' - - value: 7 - label: '7' - - value: 8 - label: '8' - - value: 9 - label: '9' - - value: 10 - label: '10' -- id: burden - text: Чувствовали ли Вы себя обузой для близких? - options: *id001 - weekly: true +# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the +# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»). +ptsd: + form: pcl5 + items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18] + question: 'Насколько сильно сегодня Вас беспокоили:' diff --git a/config/intake_runtime.yaml b/config/intake_runtime.yaml index 0d187f4..2a13428 100644 --- a/config/intake_runtime.yaml +++ b/config/intake_runtime.yaml @@ -1,17 +1,27 @@ -version: intake-v2-2026-10-03 +version: intake-v3-2026-10-04 enabled: false approval: null # Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness. content_review: null contact_review: null timezone: Europe/Moscow -daily_hour: 19 -mood_baseline_days: 7 -mood_bad_days: 3 -mood_drop: 3 -sleep_bad_days: 3 +# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00. +morning_hour: 8 +evening_hour: 20 +# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`. +# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor. +norm_days: 14 +week_days: 7 +min_norm_values: 7 +min_week_values: 4 +mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9 +depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier +ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier +sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor sleep_hours_below: 4 -nightmares_at: 3 +alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3) +alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT +adherence_below: 0.8 # share of days with medication taken over a week missed_medication_days: 3 high_risk_silence_days: 2 medication_days: [7, 14, 28] diff --git a/config/questionnaire_demo.yaml b/config/questionnaire_demo.yaml new file mode 100644 index 0000000..77c4f50 --- /dev/null +++ b/config/questionnaire_demo.yaml @@ -0,0 +1,21 @@ +# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is +# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md). +# Values follow the MVP PDF so the demo can reach a result and stage 2: +# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table); +# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022). +version: mvp-v2-demo-2026-10-04 +approval: + reviewer: "DEMO — синтетические данные, не клиническое согласование" + decision_reference: "INTAKE_DEMO" + approved_at: "2026-10-04T00:00:00+03:00" + policy_version: mvp-v2-demo-2026-10-04 +cssrs_positive_policy: graded +rus_audit_s_expand_from_zone: 2 +phq2_expand_at: 2 +tsq_expand_at: 6 +phq9_psychologist_at: 5 +phq9_specialist_at: 10 +phq9_priority_at: 20 +pcl5_specialist_at: 31 +pcl5_priority_at: 50 +pcl5_behavior_flag_at: 3 diff --git a/config/texts/intake.yaml b/config/texts/intake.yaml index 0e88489..d33f1c7 100644 --- a/config/texts/intake.yaml +++ b/config/texts/intake.yaml @@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн tbi: "Были ли у Вас контузия или черепно-мозговая травма?" psychotropics: "Принимаете ли Вы сейчас психотропные препараты?" missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже." +side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?" +numeric_portions: "Введите число стандартных порций, например 0 или 2." +diary_done: "Дневник на сегодня уже заполнен. Спасибо!" periods: + since_last_contact: "С момента Вашего последнего ответа на эти вопросы." past_month: "За последний месяц." lifetime: "За всю жизнь." past_3_months: "За последние три месяца." diff --git a/config/texts/results.yaml b/config/texts/results.yaml new file mode 100644 index 0000000..0165968 --- /dev/null +++ b/config/texts/results.yaml @@ -0,0 +1,120 @@ +# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are +# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для +# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result. +disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации." + +intake: + ok: + title: "Признаков депрессии и ПТСР не выявлено" + text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше." + attention: + title: "Есть признаки, которые стоит обсудить со специалистом" + text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше." + +findings: + depression_suspected: + title: "Предположение депрессии" + text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике." + depression_mild: + title: "Лёгкие симптомы депрессии" + text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить." + ptsd_suspected: + title: "Склонность к ПТСР" + text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике." + ptsd_signs: + title: "Отдельные признаки посттравматического стресса" + text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР." + alcohol_risky: + title: "Рискованное употребление алкоголя" + text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом." + alcohol_high: + title: "Высокий риск зависимости от алкоголя" + text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом." + suicide_low: + title: "Тяжёлые мысли" + text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия." + tbi: + title: "Контузия или ЧМТ" + text: "Рекомендуем консультацию невролога." + +routes: + urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра." + priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни." + psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту." + psychologist: "Рекомендуем консультацию психолога." + self_help: "Достаточно самопомощи и короткого ежедневного дневника." + +modules: + title: "Что дальше" + diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию." + names: + depression: "«Депрессия»" + veteran: "«ПТСР»" + with: ", плюс модуль {names}" + +scales: + phq9: + title: "Опросник PHQ-9 (депрессия)" + levels: + minimal: "минимальные симптомы" + mild: "лёгкие симптомы" + moderate: "умеренные симптомы" + moderately_severe: "умеренно тяжёлые симптомы" + severe: "тяжёлые симптомы" + pcl5: + title: "Опросник PCL-5 (ПТСР)" + levels: + below: "ниже порога вероятного ПТСР" + probable: "выше порога вероятного ПТСР" + rus_audit_s: + title: "Опросник RUS-AUDIT-S (алкоголь)" + levels: + zone_1: "относительно низкий риск" + zone_2: "опасное употребление" + zone_3: "пагубное употребление" + zone_4: "риск зависимости" + unknown: "нельзя оценить без указания пола" + score: "{title}: {total} — {level}." + change: + remission: "Это ремиссия по шкале — отличный результат." + response: "По сравнению с началом наблюдения — ответ на лечение." + improved: "По сравнению с началом наблюдения — клинически значимое улучшение." + stable: "По сравнению с началом наблюдения существенных изменений нет." + worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план." + +diary: + saved: "Спасибо, дневник за {part} сохранён." + parts: + morning: "утро" + evening: "вечер" + flags: + phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока." + phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока." + pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока." + sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке." + alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку." + rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе." + medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему." + adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом." + side_effects_review: "Вы отметили побочные эффекты — врач получит отметку." + +cssrs: + none: "Спасибо за ответы." + low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело." + +kinds: + intake: "Первичный опрос" + diary_morning: "Дневник — утро" + diary_evening: "Дневник — вечер" + cssrs_since_contact: "Вопросы о безопасности" + phq9: "PHQ-9 по расписанию" + pcl5: "PCL-5 по расписанию" + rus_audit_s: "Алкоголь по расписанию" + diary: "Дневник" +status: + complete: "пройден" + review_required: "остановлен для оценки специалиста" + interrupted: "прерван: открыта экстренная помощь" + active: "в процессе" + paused: "на паузе" + cancelled: "отменён" diff --git a/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/docs/DECISIONS.md b/docs/DECISIONS.md index b3a1da2..6ec03da 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3,6 +3,12 @@ > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. +## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату +**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста». +**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется. +**Причина:** пациенту нужен понятный итог и путь дальше, а не тупик; решения о риске и маршруте при этом остаются детерминированными (ADR-007). +**Последствия:** отступления от присланного PDF сохранены по `docs/clinical/README.md`: TSQ вместо PC-PTSD-5 (v2 PDF), RUS-AUDIT-S — пункты 3/9/10 (ВОЗ). Открыто: номера 4 пунктов короткой PCL-5 (Price et al., 2016), официальная форма C-SSRS Since Last Contact (сейчас — тексты Recent с периодом «с последнего ответа»), пороги «роста» модулей дневника — согласовать с врачом. + ## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами **Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34). **Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача. diff --git a/docs/info/RAG_GUIDELINES.md b/docs/info/RAG_GUIDELINES.md new file mode 100644 index 0000000..a53e72b --- /dev/null +++ b/docs/info/RAG_GUIDELINES.md @@ -0,0 +1,142 @@ +# RAG по клиническим рекомендациям Минздрава + +> Статус: черновик подхода, 2026-10-04. Реализация — отдельной задачей (lane B, `app/llm/rag.py` по SPEC §структура). +> Пример исходника: КР «Посттравматическое стрессовое расстройство», ID 753, 2023 (Российское общество психиатров). + +## Принцип + +Файлов мало, глубина нужна максимальная, поэтому мы **не** делаем классический RAG «нарезка по N токенов → эмбеддинги → top-k». +Каждая КР один раз превращается в **размеченную и вычитанную базу знаний** из смысловых единиц. Поиск по ней почти тривиален, а +каждый ответ проверяется по цитате. + +## 1. Кому отвечает RAG + +КР написаны для врачей: дозы, препараты off-label (`#`), показания к госпитализации. Пересказывать это пациенту нельзя — +это противоречит нашим guardrails (`app/llm/med_qa.py`: о препаратах — только по инструкции ГРЛС и без доз). + +| Потребитель | Что можно отдавать из КР | +|---|---| +| Пациент (бот) | Только `Приложение В. Информация для пациента` и психообразование. Без доз и названий препаратов | +| Врач (дашборд) | Всё, с цитатой, уровнем убедительности (УУР) и достоверности доказательств (УДД) | +| Команда (design-time) | Обоснование порогов в `config/` (TSQ ≥ 6 — Прил. Г1, градации шкалы Бека — Г10): ссылки на источник, а не runtime-RAG | + +У каждой единицы есть поле `audience`. Фильтр по нему применяется **до** поиска, а не через промпт LLM. + +## 2. Разбивка на смысловые единицы + +Структура КР Минздрава стабильна, используем её как схему. + +| `type` | Пример из КР ПТСР | Как хранить | +|---|---|---| +| `recommendation` | «Пациентам с ПТСР … рекомендуется начинать терапию с СИОЗС…» | Тезис + УУР/УДД + комментарий + номера источников | +| `definition` / `criteria` | Критерии МКБ-10 А–Д, кластеры МКБ-11 | По одному критерию на единицу | +| `dose_row` | Таблица «Рекомендуемые дозы» (Прил. А3) | Строка: препарат, диапазон, `off_label`, `zhnvlp` (**) | +| `scale` | TSQ, CAPS, IES-R, BHS, TOP-8 (Прил. Г1–Г11) | Пункты + ключ интерпретации как данные | +| `algorithm` | Прил. Б и Б1 (в PDF это картинки) | Переписать вручную в шаги и ветвления | +| `patient_info` | Прил. В | Абзацы с `audience: patient` | +| `quality_criterion` | «Критерии оценки качества медицинской помощи» | По строке таблицы | + +Пример единицы: + +```json +{ + "id": "kr753.3.2.rec01", + "doc": {"kr_id": 753, "title": "ПТСР", "approved": "2023", "revise_by": "2025"}, + "path": ["3. Лечение", "3.2. Психофармакотерапия", "Терапия первой линии"], + "type": "recommendation", + "text": "Пациентам с ПТСР в качестве препаратов первой линии ... рекомендуется начинать терапию с СИОЗС ...", + "comment": "После установления диагноза ... эффективность оценивается через 4–6 недель ...", + "uur": "A", + "udd": 1, + "refs": [51, 53, 302], + "drugs": ["пароксетин", "сертралин", "флуоксетин", "венлафаксин"], + "contains_dose": true, + "off_label": ["флуоксетин", "венлафаксин"], + "audience": ["doctor"], + "pages": [17, 18] +} +``` + +Комментарий хранится вместе с тезисом: по отдельности они теряют смысл, а основная глубина КР — именно в комментариях. + +## 3. Пайплайн подготовки: автоматика, затем вычитка + +Файлов мало, поэтому ручная вычитка реальна и даёт больше всего качества. Повторяем практику `docs/clinical/`: хеши +исходников, `review-status.json`. + +1. **Источник.** Первоисточник — cr.minzdrav.gov.ru, а не перевёрстки (пример выше — перевёрстка с disuria.ru). Храним sha256. +2. **Очистка.** Убрать колонтитулы («Страница N из 117», рекламу сайта), склеить переносы и разрывы страниц + (слипания вида «клиникопсихопатологических»), собрать таблицы, разорванные между страницами. +3. **Парсер по шаблону.** Абзац «- Пациентам…» до строки «Уровень убедительности рекомендаций X (уровень достоверности + доказательств N)» — одна единица `recommendation`. Следующий за ней блок «Комментарии:» — её поле `comment`. +4. **Список литературы не индексируем.** Это шум для поиска; номера источников остаются в `refs`. +5. **Ручная вычитка.** В первую очередь — единицы для пациента и всё, где есть числа. В КР ПТСР уже видны ошибки самого + источника, которые автоматика пропустит: + - в таблице критериев качества перепутаны колонки УДД и УУР; + - раздел 6.1 встречается дважды; + - опечатка «ценить суицидальный риск» вместо «оценивать». +6. **Словарь аббревиатур и синонимов** — из списка сокращений в начале каждой КР. Нужен для расширения запросов: + СИОЗС ↔ сертралин/пароксетин, ДПДГ ↔ EMDR, ТФ-КПТ и т.д. + +Раскладка по ADR-008: + +``` +data/kb/sources/ исходные PDF + sha256 +data/kb/units/*.jsonl вычитанные единицы (в git) +data/kb/review-status.json статус вычитки по единицам +data/eval/kb_questions.yaml эталонные вопросы +``` + +## 4. Поиск + +Корпус маленький, тяжёлая инфраструктура не нужна. + +- **Фильтр по метаданным до поиска:** `audience`, `doc`, `type`. Вопрос пациента о дозе — отказ без поиска. +- **Гибридный поиск:** + - BM25 с русской лемматизацией (pymorphy3) и расширением запроса по словарю аббревиатур; + - эмбеддинги — только GigaChat Embeddings или локальная модель (guardrails 4 и 9; для офлайн-демо — только локальная). +- **Small-to-big.** Найден тезис — в контекст LLM уходит тезис с комментарием и заголовками родительского раздела. При + нашем объёме допустимо отдавать раздел целиком. +- **Порог уверенности.** Лучшая единица ниже порога — ответ «В клинических рекомендациях ответа на этот вопрос нет» + (по аналогии с `fallback` в `config/texts/med_qa.yaml`). + +## 5. Генерация строго по источнику + +Переиспользуем механизмы `app/llm/med_qa.py`: + +- LLM получает пронумерованные единицы и обязана ссылаться на `id` в каждом утверждении. +- **Детерминированная проверка после генерации:** + - каждое число в ответе встречается в процитированных единицах; + - доля «новых» слов ограничена (`MAX_NOVEL_STEM_SHARE`). + + Не прошло — вместо пересказа отдаём саму цитату. +- Ссылка на источник («КР ПТСР 2023, разд. 3.2, УУР A») и фраза «решение принимает врач» добавляются кодом, не через LLM. +- До поиска действуют общие guardrails: проверка безопасности (`acute` → фиксированный кризисный скрипт), затем `scrub()`, + затем вызов модели только через `app/llm/gateway.py`. + +## 6. Оценка качества + +Эталонный набор в `data/eval/` на 50–100 вопросов с ожидаемыми `id` единиц. Вопросы трёх видов: + +- прямые; +- перефразированные; +- ловушки, на которые в КР ответа нет. + +Метрики: + +- recall@k по `id` (есть ли нужные единицы среди первых k найденных); +- доля ответов, прошедших проверку по цитате; +- доля правильных отказов на ловушках. + +Оценка запускается обычным pytest-тестом, чтобы правки разметки не ломали поиск незаметно. + +## Риски и ограничения + +- **Актуальность.** КР ПТСР указывает «пересмотр не позднее 2025». Перед использованием проверить в рубрикаторе Минздрава + наличие новой редакции. `approved` / `revise_by` храним в метаданных и показываем в ответе. +- **Объём к дедлайну (05.10.2026, 13:00).** Реалистичный MVP: + - 2–3 КР (ПТСР, депрессия), размеченные полуавтоматически и вычитанные; + - BM25 + фильтр по `audience`; + - строгие цитаты. + + Эмбеддинги и реранкер — по остаточному принципу: глубину даёт качество разметки, а не векторная база. diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_50-1.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_50-1.png new file mode 100644 index 0000000..70a6fb2 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_50-1.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_50-2.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_50-2.png new file mode 100644 index 0000000..ae9680d Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_50-2.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_51-3.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_51-3.png new file mode 100644 index 0000000..611788b Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_51-3.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_52-4.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_52-4.png new file mode 100644 index 0000000..3748c55 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_52-4.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_52-5.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_52-5.png new file mode 100644 index 0000000..c2118ef Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_52-5.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_53-6.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_53-6.png new file mode 100644 index 0000000..1bd63d8 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_52_53-6.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_10-1.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_10-1.png new file mode 100644 index 0000000..c6c0b98 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_10-1.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_10-2.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_10-2.png new file mode 100644 index 0000000..23ac880 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_10-2.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_11-3.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_11-3.png new file mode 100644 index 0000000..a0eb395 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_11-3.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_12-4.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_12-4.png new file mode 100644 index 0000000..5131a6d Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_12-4.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_13-5.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_13-5.png new file mode 100644 index 0000000..4fe3f0f Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_13-5.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_13-6.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_13-6.png new file mode 100644 index 0000000..2b5c7e5 Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_13-6.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_14-7.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_14-7.png new file mode 100644 index 0000000..8b2b92a Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_14-7.png differ diff --git a/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_15-8.png b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_15-8.png new file mode 100644 index 0000000..ee11d6c Binary files /dev/null and b/docs/mock/Изображение ChatGPT 4 окт. 2026 г., 07_53_15-8.png differ diff --git a/docs/product/03_mockups_brief.md b/docs/product/03_mockups_brief.md new file mode 100644 index 0000000..74bbcfc --- /dev/null +++ b/docs/product/03_mockups_brief.md @@ -0,0 +1,323 @@ +# Запрос на мокапы: приложение пациента «Сопровождение пациента» + +Хакатон MedITron, кейс SberMed AI «ИИ-помощник пациента». По мокапам сразу пишется код (React, затем мобильное +приложение через Capacitor), поэтому важны точные токены, реальные тексты и все состояния экранов. + +--- + +## 1. О продукте + +**Что это.** Сервис сопровождения между визитами к врачу: напоминает о лекарствах, раз в день спрашивает о +самочувствии, по расписанию проводит короткие медицинские анкеты и сообщает врачу, если состоянию нужно +внимание. ИИ не ставит диагнозы и не лечит. + +**Для кого.** Ветераны боевых действий 25–55 лет с ПТСР и депрессией, позже — их семьи. Часто уставшие, +недоверчивые к «приложениям», могут быть в тяжёлом состоянии. Пользуются в основном телефоном. + +**Главная задача интерфейса.** Ежедневная отметка самочувствия и приёма лекарства занимает меньше минуты и не +вызывает тревоги. Человеку хочется её заполнять. + +**Два способа входа.** +- Через ЕМИАС: врач видит ответы и получает сигнал при ухудшении. +- Анонимно: без имени и полиса, врач ничего не видит. + +--- + +## 2. Требования к мокапам + +### Формат +- Мобильные кадры **390×844**, по одному экрану на кадр. +- Для A1 и C1 дополнительно десктоп **1440×1000**. +- Отдать **PNG** и, если есть, ссылку на Figma с открытым просмотром. +- Имена файлов по ID экрана: `B3-scale.png`, `C1-today.png`, `C1-today-desktop.png`. +- Состояния — отдельными кадрами с суффиксом: `D1-mood-selected.png`, `S2-offline.png`. + +### Содержание +- **Только реальные тексты на русском** из этого документа. Никаких lorem ipsum и «Заголовок». +- **Без логотипов ЕМИАС, mos.ru, Госуслуг и Минздрава.** Вход — текстовая кнопка «Войти через ЕМИАС». +- **Баллы анкет, диагнозы и уровни тяжести пациенту не показываются нигде.** +- **Кнопка «Нужна помощь»** (звонок 112) есть в шапке каждого экрана. +- Без иллюстраций людей, стоковых фото, эмодзи, градиентов, стеклянных эффектов и 3D. +- Учесть вырез экрана сверху и home indicator снизу. + +### Состояния (там, где они есть у экрана) +Обычное · выбранный вариант · кнопка неактивна · загрузка · ошибка с понятным действием. + +--- + + +## 4. Общий промпт + +Вставляется перед промптом любого экрана из раздела 5. + +``` +Спроектируй экран мобильного приложения (390×844) «Сопровождение пациента» — сервиса сопровождения +между визитами к врачу. Пользователи — ветераны боевых действий 25–55 лет с ПТСР и депрессией, часто +уставшие и недоверчивые. Цель — чтобы ежедневные отметки занимали меньше минуты и не вызывали тревоги. + +Стиль: строгий государственный медицинский сервис уровня mos.ru и Госуслуг, но тёплый и спокойный. +Без логотипов ЕМИАС, mos.ru и Госуслуг. Без иллюстраций людей, стоковых фото, эмодзи, градиентов, +стекла и 3D. Много воздуха, чёткая иерархия, крупный текст. + +Правила: кнопки не ниже 48 px, основная кнопка на всю ширину внизу; один вопрос — один экран, +варианты ответа — крупные карточки на всю ширину, выбранный подсвечивается цветом направления; +в шапке красная кнопка «Нужна помощь»; нижняя навигация «Сегодня · Лекарства · Сообщения · Профиль» +(кроме экранов входа и анкет). Баллы, диагнозы и оценки тяжести не показывать. Тексты на русском, +короткие, на «вы», без медицинского жаргона и без давления. Учти вырез экрана и home indicator. +``` + +--- + +## 5. Экраны и промпты + +Приоритет: **волна 1** нужна в первую очередь — это ядро ежедневного сценария. + +### Волна 1 + +**C1 · Сегодня (главная)** +``` +Экран «Сегодня». Приветствие «Добрый день» и дата «Воскресенье, 4 октября». Список задач дня карточками +с временем слева и цветной полосой направления: «09:00 · Утренний приём» (лекарства, отмечено +«Принято»), «14:00 · Как вы сегодня?» (самочувствие, кнопка «Ответить», «≈ 1 минута»), +«Анкета о сне и настроении» (самочувствие, «≈ 5 минут», «до 11 октября»). Ниже блок +«Следующий визит: 12 октября, 10:30, врач Смирнова Е. А.». Быстрые действия: «Вопрос о лекарстве», +«Написать врачу». Нижняя навигация, активна вкладка «Сегодня». Отдельно десктоп 1440×1000. +``` + +**C2 · Всё сделано** +``` +Экран «Сегодня», когда задачи выполнены: спокойное пустое состояние «На сегодня всё. Следующее +напоминание — завтра в 09:00». Все карточки дня отмечены как выполненные. Без конфетти и наград. +``` + +**D1 · Настроение** +``` +Первый шаг ежедневной отметки. Прогресс «1 из 5», кнопка «Назад». Вопрос крупно: «Как вы себя +чувствуете сегодня?». Шкала 0–10 крупными кнопками с цветовой градацией от тёплого красного к +зелёному, подписи по краям «Очень тяжело» и «Отлично». Состояния: ничего не выбрано; выбрано 6. +Кнопка «Дальше» внизу. +``` + +**D2 · Лекарство сегодня** +``` +Шаг 2 из 5. «Вы приняли лекарство сегодня?» Карточки: «Да, принял(а)», «Нет, пропущу», «Не помню». +Цвет выбора — бирюзовый (лекарства). +``` + +**D3 · Сон и кошмары** +``` +Шаг 3 из 5. «Сколько часов вы спали этой ночью?» — четыре карточки «Меньше 4», «4–6», «6–8», +«Больше 8». Ниже второй вопрос на том же экране: «Снились ли кошмары?» — «Да» / «Нет». +``` + +**D4 · Алкоголь** +``` +Шаг 4 из 5. «Выпивали ли вы алкоголь вчера?» — «Нет», «1–2 порции», «Больше». Нейтральный тон, +без осуждения, подпись мелко: «Ответ видит только ваш врач». +``` + +**D5 · Пару слов** +``` +Шаг 5 из 5. «Как прошёл день? Пару слов» — поле ввода на несколько строк, подсказка «Например: +плохо спал, болит голова». Кнопки «Отправить» и «Пропустить». Клавиатура открыта. +``` + +**D6 · Готово** +``` +Итог отметки: «Спасибо, записали». Короткая сводка ответов без оценок: «Настроение 6 из 10 · +Лекарство принято · Сон 6–8 часов». Строка «Ваш врач увидит отметку в карточке». Пример ответа +сервиса: карточка «Эффект препарата наступает не сразу — обычно через 2–4 недели». Кнопка «На главную». +``` + +**E1 · Напоминание о приёме** +``` +Экран, открывающийся по уведомлению. Бирюзовый акцент. «09:00 · Время утреннего приёма». Название +препарата и «1 таблетка после еды» (без дозировок в мг). Три кнопки: основная «Принял(а)», +вторичные «Пропущу» и «Есть побочные эффекты». +``` + +**B3 · Вопрос анкеты (универсальный экран)** +``` +Экран одного вопроса анкеты. Сверху «Вопрос 3 из 20» с полосой прогресса, «Назад» и «Пауза». +Мелко период: «За последний месяц». Вопрос крупно: «Как часто вас беспокоили повторяющиеся +тревожные воспоминания о пережитом?». Пять карточек-вариантов: «Совсем нет», «Немного», +«Умеренно», «Сильно», «Очень сильно». Без баллов рядом с ответами. Состояния: не выбрано; выбрано +«Умеренно». Отдельно экран паузы: «Можно продолжить позже — ответы сохранены». +``` + +**G2 · Кризисный экран** +``` +Полноэкранный экран поддержки. Спокойный, не пугающий: белый фон, красный используется только для +кнопок звонка. Текст: «Мне очень жаль, что вам сейчас так тяжело. Вы не одни». Две крупные +кнопки звонка: «112 — если есть угроза жизни прямо сейчас» и «+7 (495) 989-50-50 — Психологическая +помощь МЧС, круглосуточно». Блок дыхания: круг, который плавно расширяется и сжимается, подпись +«Вдох на 4 счёта — выдох на 6». Внизу «Я рядом». +``` + +### Волна 2 + +**A1 · Выбор входа** (уже свёрстан, нужен улучшенный вариант) +``` +Стартовый экран. Заголовок «Лечение продолжается и между приёмами», подзаголовок «Напомним о +лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание». +Две карточки выбора: «Через ЕМИАС» (метка «Рекомендуем», плюсы: назначения подгружаются +автоматически; врач видит ответы и получает сигнал при ухудшении; история на любом устройстве; +кнопка «Войти через ЕМИАС») и «Анонимно» (плюсы: не нужны имя, полис и телефон; опросы и помощь в +кризисе доступны; минусы: врач не видит ответы; история только на этом устройстве; кнопка +«Продолжить анонимно»). Отдельно десктоп 1440×1000. +``` + +**A2 · Вход через ЕМИАС** +``` +Экран входа. Плашка «Демонстрационный режим: вход подтверждается одноразовым кодом, который врач +выдаёт на приёме». Поле «Код подключения от врача» и кнопка «Сканировать QR-код». Кнопка «Войти». +Ниже разделитель «или» и список «Демо-пациенты»: строка «Андрей С. · 13-й день терапии · Войти». +Состояния: ошибка «Код не подошёл. Проверьте написание или попросите врача выдать новый код». +``` + +**A3 · Анонимный вход** +``` +«Анонимный вход». Три пункта с галочками: что доступно, что врач не видит, что нужно согласие. +Жёлтая плашка: «История привязана к этому устройству. Если вы выйдете, восстановить её будет нельзя». +Кнопка «Начать анонимно». +``` + +**A4 · Согласие на обработку данных** +``` +Экран согласия (152-ФЗ). Заголовок «Перед началом нужно ваше согласие». Короткие блоки с иконками: +«Что обрабатываем» — ответы о самочувствии, сне, приёме лекарств; «Кто видит» — только ваш лечащий +врач; «Как отозвать» — в любой момент в профиле. Ссылка «Полный текст согласия». Кнопки «Согласен(на)» +и «Не согласен(на)» равного веса — без давления. +``` + +**A5 · Возраст** +``` +«Вам уже исполнилось 18 лет?» Две карточки: «Да, мне 18 или больше», «Нет, мне меньше 18». +``` + +**A6 · Отказ или младше 18** +``` +Спокойный информационный экран без упрёка: «Без согласия мы не можем сохранять ваши ответы. Если +передумаете — нажмите „Начать заново“». Блок «Если нужна помощь сейчас» с номерами. Вариант для +младше 18: «Сервис рассчитан на взрослых» и детский телефон доверия 8-800-2000-122. +``` + +**B1 · Вступление к анкете** +``` +«Сначала несколько вопросов». Абзац «Многие после возвращения замечают проблемы со сном, +раздражительность, тревогу — это частая реакция, и с ней можно справиться». Раскрывающиеся +вопросы FAQ: «Зачем эти вопросы?», «Кто увидит ответы?», «Можно ли прерваться?». Подпись +«≈ 10 минут, можно с перерывами». Кнопка «Начнём». +``` + +**B2 · Травматический опыт** +``` +«Был ли у вас опыт, связанный с угрозой жизни — вашей или других людей?» Карточки «Да», «Нет», +«Не хочу отвечать». Деликатно, без подробностей события. +``` + +**B4 · Доступ к оружию** +``` +«Есть ли у вас дома оружие?» Карточки: «Нет», «Да, у меня», «Да, хранится у других или в сейфе», +«Не хочу отвечать». Отдельный кадр — блок о безопасном хранении: спокойный тон, совет хранить +оружие и патроны отдельно или передать на время близкому человеку. +``` + +**B5 · Итог анкеты** +``` +«Спасибо, анкета заполнена». Без баллов и диагнозов: «Ваш врач получит результаты к следующему +визиту». Блок «Что дальше»: ежедневная отметка в 14:00, напоминания о приёме, повторная анкета +через 2 недели. Кнопка «Перейти на главную». +``` + +**E2 · Причина пропуска** +``` +«Почему пропускаете приём?» Карточки: «Забыл(а)», «Побочные эффекты», «Решил(а) бросить», «Другое». +Подпись: «Ответ поможет врачу подобрать лечение». +``` + +**E3 · Побочные эффекты** +``` +«Что беспокоит?» Чипы с множественным выбором: тошнота, бессонница, сонливость, головная боль, +снижение аппетита, другое. При «Другое» — поле ввода. Кнопка «Отправить». +``` + +**E4 · Моя схема (вкладка «Лекарства»)** +``` +Вкладка «Лекарства». Карточка препарата: название, «1 таблетка утром после еды», время 09:00. +Полоса отметок за 7 дней: принято / пропущено / нет отметки. Блок «Вопрос о лекарстве». +Строка «Схему меняет только ваш врач». +``` + +**E5 · Вопрос о лекарстве** +``` +Поле вопроса и пример ответа: карточка «По инструкции к препарату: …» с подписью источника +«Инструкция ГРЛС». Внизу всегда: «Не меняйте схему приёма самостоятельно — решения принимает врач». +Состояние «В инструкции нет ответа — уточните у врача». +``` + +**F1 · Написать врачу** +``` +«Сообщение врачу». Поле ввода, подсказка «Врач ответит в рабочее время». Красная плашка: «Если +помощь нужна сейчас — звоните 112». Кнопка «Отправить». Экран успеха: «Сообщение передано». +``` + +**F2 · Следующий визит** +``` +Дата и время визита, врач, адрес поликлиники. Список «Что обсудить на приёме» — можно добавить +свой пункт. Кнопка «Добавить в календарь». +``` + +**G1 · Нужна помощь** (шторка снизу) +``` +Нижняя шторка поверх любого экрана: «Если вам сейчас очень тяжело». Две крупные строки звонка с +номерами 112 и +7 (495) 989-50-50, кнопка «Позвонить» у каждой. +``` + +**G3 · План безопасности** +``` +«Мой план безопасности». Шаги: «Мои тревожные сигналы», «Что мне помогает успокоиться», «Кому я +могу позвонить» (контакты), «Куда обратиться» (номера служб). Каждый шаг можно редактировать. +``` + +**G4 · Вопросы о безопасности** +``` +Экран вопроса из опросника риска. Очень спокойная подача, без красного цвета. Подпись: «Мы +спрашиваем об этом всех — это помогает вовремя поддержать». Ответы «Да» / «Нет». +``` + +**H1 · Профиль** +``` +Вкладка «Профиль». Способ входа (ЕМИАС или анонимно). Для анонимного — кнопка «Подключить ЕМИАС». +Настройки: время ежедневной отметки, уведомления о приёме. Разделы «Мои данные и согласие», «Выйти». +``` + +**H2 · Данные и согласие** +``` +«Мои данные». Дата согласия, ссылка на текст. Кнопка «Отозвать согласие» с подтверждением: +«Обработка ответов прекратится, данные будут помечены к удалению». Для анонимного — «Удалить мои данные». +``` + +**I1 · Сообщения** +``` +Вкладка «Сообщения» — чат с помощником. Сообщения помощника слева на белом, ответы пациента справа +на синем. Кнопки ответа под сообщением — карточками. Поле ввода внизу. +``` + +**S1–S4 · Системные состояния** +``` +Четыре кадра: загрузка (скелетон главной); нет сети («Нет соединения — ответы отправятся, когда +появится сеть», кнопка «Нужна помощь» работает); ошибка («Не удалось загрузить. Повторить»); +сессия истекла («Войдите снова»). +``` + +--- + +## 6. Чек-лист приёмки + +- [ ] Все кадры 390×844, имена файлов по ID. +- [ ] Цвета, шрифт и скругления — из раздела 3. +- [ ] Кнопка «Нужна помощь» на каждом экране, кроме кризисного (там она заменена звонками). +- [ ] Нигде нет баллов анкет, диагнозов и логотипов ЕМИАС, mos.ru, Госуслуг. +- [ ] Тексты реальные, на русском; у вопросов есть выбранное состояние. +- [ ] Кнопки не ниже 48 px; текст не мельче 12 px. 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 new file mode 100644 index 0000000..e8f5ab5 --- /dev/null +++ b/tests/test_api_patient_web.py @@ -0,0 +1,222 @@ +"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine.""" + +from collections.abc import AsyncIterator +from datetime import datetime, time, timedelta + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.api import fixtures +from app.core import clock +from app.db import identity_repo, repo, session +from app.db.bootstrap import init_db +from app.db.clinical import Dose, Prescription +from app.main import app + +MEM = "sqlite+aiosqlite:///:memory:" +TOKEN = "b" * 32 + + +@pytest.fixture +async def client() -> AsyncIterator[AsyncClient]: + await session.configure(identity_url=MEM, clinical_url=MEM) + await init_db() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + await session.configure() + + +async def _patient_with_code(code: str) -> None: + async with session.get_identity_session() as s: + await identity_repo.create_patient( + s, + token=TOKEN, + display_name="Синтетический П.", + snils="000-000-000 00", + clinic_id="clinic-1", + doctor_id="doc-1", + now=clock.now(), + ) + await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now()) + + +async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: AsyncClient) -> None: + first = (await client.post("/api/v1/auth/anonymous")).json() + second = (await client.post("/api/v1/auth/anonymous")).json() + assert first["mode"] == "anonymous" and first["display_name"] is None + assert len(first["token"]) == 32 and first["token"] != second["token"] + async with session.get_identity_session() as s: + assert await identity_repo.get_patient(s, first["token"]) is None + async with session.get_clinical_session() as s: + # Only a doctor-less pseudonymous profile: the dialog API serves known profiles only. + profile = await repo.get_profile(s, first["token"]) + assert profile.doctor_id == "" and profile.modules == [] + + +async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"] + async with session.get_clinical_session() as s: + profile = await repo.get_profile(s, token) + assert profile.modules == ["veteran"] and not profile.doctor_id + assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False + assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422 + + +async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous")).json()["token"] + replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"] + buttons = [b["data"] for r in replies for row in r["buttons"] for b in row] + assert "consent:yes" in buttons and "consent:no" in buttons + + +async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None: + await _patient_with_code("DEMO-0042") + ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "}) + assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None} + async with session.get_clinical_session() as s: + assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1" + again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"}) + assert again.status_code == 401 + + +async def test_emias_rejects_unknown_code(client: AsyncClient) -> None: + assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401 + assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422 + + +async def test_emias_demo_persona(client: AsyncClient) -> None: + persona = fixtures.DEMO_PERSONAS[0] + res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token}) + assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name} + async with session.get_clinical_session() as s: + assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID + missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"}) + assert missing.status_code == 404 + + +async def test_today_plan_and_week_marks(client: AsyncClient) -> None: + now = clock.now() + today = now.date() + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5)) + rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]}) + s.add(rx) + await s.flush() + morning = datetime.combine(today, time(9, 0), now.tzinfo) + s.add_all( + [ + Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"), + Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)), + ] + ) + await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6) + + body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json() + assert body["has_doctor"] and body["checkin_done"] + assert body["next_visit"] == (today + timedelta(days=5)).isoformat() + assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")] + week = body["prescriptions"][0]["week"] + assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending" + + +async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None: + token = (await client.post("/api/v1/auth/anonymous")).json()["token"] + body = (await client.get(f"/api/v1/me/{token}/today")).json() + assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == [] + + +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, "diary_next": "morning"} + + +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") + # Stage 1 starts with PHQ-2, in the order of the MVP questionnaire. + assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons) + assert "intake:help" in buttons and "intake:pause" in buttons + q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"] + assert q["enabled"] and q["intake"] == "active" + + +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"]["alcohol_portions"]["numeric"] + assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary" + assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale" + drinks = body["standard_drinks"] + assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"]) + + +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()) + + +async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: + # Stage 1 is the same for everyone: it does not wait for a module (it sets them itself). + monkeypatch.setenv("INTAKE_DEMO", "1") + token = (await client.post("/api/v1/auth/anonymous")).json()["token"] + + async def press(button: str | None = None) -> list[str]: + event = {"type": "button", "button": button} if button else {"type": "start"} + replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"] + return [b["data"] for r in replies for row in r["buttons"] for b in row] + + assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"] + await press() + await press("consent:yes") + assert any(":phq9_1:" in b for b in await press("age:adult")) + + +async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None: + body = (await client.get("/api/v1/patient/demo-personas")).json() + assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS] diff --git a/tests/test_dialog_intake.py b/tests/test_dialog_intake.py index 37714eb..09c1bc0 100644 --- a/tests/test_dialog_intake.py +++ b/tests/test_dialog_intake.py @@ -94,6 +94,17 @@ async def complete_intake(e, choices=None): raise AssertionError("intake did not terminate") +async def to_cssrs(e, choices=None): + """Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S.""" + replies = await press(e, "intake:start") + for _ in range(20): + button = answer_button(replies, {"trauma": 1, **(choices or {})}) + if ":cssrs_" in button: + return replies + replies = await press(e, button) + raise AssertionError("C-SSRS not reached") + + async def test_minimal_intake_raw_data_not_imputed_and_restart(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) (row,) = await rows() @@ -111,13 +122,13 @@ async def test_pause_restart_and_duplicate_callbacks(released): old = answer_button(replies) await asyncio.gather(press(released, old), press(released, old)) (row,) = await rows() - assert row.answers == {"cssrs_q1": 0} + assert row.answers == {"phq9_1": 0} await press(released, "intake:pause") restarted = Engine(gateway=LLMGateway(MockTasks())) replies = await press(restarted, "intake:resume") - assert "cssrs_q2" in answer_button(replies) + assert "phq9_2" in answer_button(replies) await press(restarted, old) - assert (await rows())[0].answers == {"cssrs_q1": 0} + assert (await rows())[0].answers == {"phq9_1": 0} assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) @@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal): if signal == "help": replies = await press(released, "intake:help") elif signal == "cssrs": + replies = await to_cssrs(released) for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): replies = await press(released, answer_button(replies, {item: 0})) else: @@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released): async def test_low_risk_shows_safety_plan_but_moderate_stops(released): - replies = await press(released, "intake:start") + replies = await to_cssrs(released) replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) replies = await press(released, answer_button(replies)) replies = await press(released, answer_button(replies)) assert any(r.kind == "safety_plan" for r in replies) - assert "phq9_1" in answer_button(replies) + # The screener ends stage 1; the context questions follow (sex is known from the profile). + assert ":tbi:" in answer_button(replies) async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch): @@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp monkeypatch.setattr(integrations, "outbox", Failing()) await jobs.run_until_now() tasks = await rows(QuestionnaireTask) - assert len(tasks) == 2 - risk = next(t for t in tasks if t.kind == "cssrs_since_contact") - assert risk.delivery and risk.sent_at is None + # 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS. + assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"} + morning = next(t for t in tasks if t.kind == "diary_morning") + assert morning.delivery and morning.sent_at is None outbox = MemoryOutbox() monkeypatch.setattr(integrations, "outbox", outbox) await jobs.run_until_now() @@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp assert len(await rows()) == 2 -async def test_missing_since_contact_is_manual_review_not_recent(released): +async def test_since_contact_risk_check_opens_the_evening_diary(released): async with session.get_clinical_session() as s: task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW) task_id = task.id replies = await released.handle( TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) ) - (row,) = await rows() - assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers - assert row.details["stop_reason"] == "cssrs_since_contact_required" - assert not answer_button(replies) + assert any("последнего ответа" in r.text for r in replies) + for _ in range(5): + button = answer_button(replies) + if ":cssrs_" not in (button or ""): + break + replies = await press(released, button) + risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") + assert risk.status == "complete" and risk.answers == { + "cssrs_q1": 0, + "cssrs_q2": 0, + "cssrs_q6_lifetime": 0, + "cssrs_q6_recent": 0, + } + assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True + assert ":mood:" in answer_button(replies) + assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows()) async def test_disabled_or_no_adult_confirmation_cannot_start(released): @@ -256,6 +282,20 @@ async def review_risk_record(e): ) +async def run_diary(e, replies, choices=None, numbers=("6,5", "2")): + """Answers the open diary questions; numeric items take `numbers` in turn.""" + numbers = list(numbers) + for _ in range(30): + button = answer_button(replies, choices) + if button: + replies = await press(e, button) + elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers: + replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0))) + else: + return replies + raise AssertionError("diary did not terminate") + + async def test_doctor_review_is_audited_and_unblocks_diary(released): from fastapi import HTTPException @@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released): from app.db.audit import AuditLog await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) - await jobs.run_until_now() + async with session.get_clinical_session() as s: + (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True + replies = await run_diary(released, await press(released, "menu:checkin")) + # Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist. + replies = await press(released, "menu:checkin") + replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0}) risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") + assert risk.status == "review_required" with pytest.raises(HTTPException) as exc: await review( TOKEN, @@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released): assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required") await review_risk_record(released) await jobs.run_until_now() - assert any(r.kind == "diary" and r.status == "active" for r in await rows()) + assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows()) await history(TOKEN, "synthetic-doctor") audits = await rows(AuditLog) assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"} -async def test_diary_numeric_skip_and_not_prescribed(released): +async def test_diary_morning_then_evening_with_the_ptsd_module(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) - await press(released, "menu:checkin") - await review_risk_record(released) replies = await press(released, "menu:checkin") - for _ in range(30): - q = next( - (b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")), - None, - ) - if q == "comment": - button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip")) - replies = await press(released, button) - elif q: - replies = await press(released, answer_button(replies, {"medication": 3})) - elif any("часов" in r.text for r in replies): - invalid = await released.handle(TOKEN, Event(type="text", text="nan")) - assert any("число часов" in r.text for r in invalid) - replies = await released.handle(TOKEN, Event(type="text", text="6,5")) - else: - break - diary = next(r for r in await rows() if r.kind == "diary") - assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5 - assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers - assert diary.answers["comment"] is None + assert any("часов" in r.text for r in replies) + invalid = await released.handle(TOKEN, Event(type="text", text="nan")) + assert any("число часов" in r.text for r in invalid) + replies = await run_diary(released, invalid, {"sleep_quality": 3}) + assert any("утро" in r.text for r in replies) + morning = next(r for r in await rows() if r.kind == "diary_morning") + assert morning.status == "complete" + assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0} + replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2}) + evening = next(r for r in await rows() if r.kind == "diary_evening") + assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed" + assert "medication_reason" not in evening.answers + assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers) + assert evening.details["interpretation"]["kind"] == "diary_evening" + replies = await press(released, "menu:checkin") + assert any("уже заполнен" in r.text for r in replies) -async def test_high_risk_diary_asks_safety_first_and_stops(released): +async def test_depression_module_item9_stops_with_crisis(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) async with session.get_clinical_session() as s: - (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True - await press(released, "menu:checkin") - await review_risk_record(released) - replies = await press(released, "menu:checkin") - button = answer_button(replies) - assert "death_thoughts" in button - replies = await press(released, button) + await repo.upsert_profile(s, token=TOKEN, modules=["depression"]) + monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) + await run_diary(released, await press(released, "menu:checkin")) + replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1}) assert any(r.kind == "crisis" for r in replies) - assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line) - diary = next(r for r in await rows() if r.kind == "diary") - assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted" + evening = next(r for r in await rows() if r.kind == "diary_evening") + assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted" async def test_unknown_sex_goes_to_review_without_a_zone(released): @@ -344,7 +380,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released): path.write_text(yaml.safe_dump(data, allow_unicode=True)) await press(released, "intake:resume") (row,) = await rows() - assert row.status == "review_required" and row.answers == {"cssrs_q1": 0} + assert row.status == "review_required" and row.answers == {"phq9_1": 0} async def test_followup_delta_uses_comparable_complete_baseline(released): @@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released): data = yaml.safe_load(path.read_text()) data["cssrs_positive_policy"] = "stop_any" path.write_text(yaml.safe_dump(data, allow_unicode=True)) - replies = await press(released, "intake:start") + replies = await to_cssrs(released) replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) assert any(r.kind == "crisis" for r in replies) - assert (await rows())[0].answers == {"cssrs_q1": 1} + assert (await rows())[0].answers["cssrs_q1"] == 1 -async def test_evening_schedule_uses_moscow_date(released, monkeypatch): +async def test_diary_schedule_uses_moscow_date(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) - monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59)) + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59)) await jobs.plan() assert await rows(QuestionnaireTask) == [] - monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0)) + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0)) await jobs.plan() - assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"} + assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"} + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0)) + await jobs.plan() + assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"} async def test_resumed_answer_keeps_original_period(released, monkeypatch): @@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch): replies = await press(released, "intake:resume") await press(released, answer_button(replies)) (record,) = await rows() - metadata = record.details["answer_metadata"]["cssrs_q2"] - assert metadata["period_start"] == "2026-09-03" + metadata = record.details["answer_metadata"]["phq9_2"] + assert metadata["period_start"] == "2026-09-19" assert metadata["period_end"] == "2026-10-03" assert metadata["answered_at"].startswith("2026-10-04") + + +async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released): + from app.api.patient_web import my_results + + choices = {f"phq9_{i}": 2 for i in range(1, 9)} + choices.update(trauma=1, tbi=1, psychotropics=1) + replies = await complete_intake(released, choices) + assert any("Предположение депрессии" in r.text for r in replies) + assert any("не диагноз" in r.text for r in replies) + (row,) = await rows() + interpretation = row.details["interpretation"] + assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"] + async with session.get_clinical_session() as s: + # A doctor's modules (diagnosis) stay; the screening only adds to them. + assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"] + results = await my_results(TOKEN) + assert results.conclusion.title == interpretation["title"] + assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден" + + +async def test_without_a_doctor_the_screening_defines_the_modules(released): + async with session.get_clinical_session() as s: + await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"]) + replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + assert any("не выявлено" in r.text for r in replies) + async with session.get_clinical_session() as s: + assert (await repo.get_profile(s, TOKEN)).modules == [] diff --git a/tests/test_dialog_meds.py b/tests/test_dialog_meds.py index 803ac9d..b36c094 100644 --- a/tests/test_dialog_meds.py +++ b/tests/test_dialog_meds.py @@ -202,6 +202,14 @@ async def test_side_effect_other_free_text(engine: Engine) -> None: assert (await checkin()).side_effects == ["headache", "other"] +async def test_side_effect_description_is_optional(engine: Engine) -> None: + reminder = await system(engine, "med_reminder") + r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other") + assert r[0].kind == "question" # the client shows an (optional) input for it + await press(engine, "text:skip") + assert (await checkin()).side_effects == ["other"] and (await get_state())[0] == "idle" + + async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None: async with session.get_clinical_session() as s: await repo.upsert_checkin( diff --git a/tests/test_intake_runtime.py b/tests/test_intake_runtime.py index 5924c84..f8b7641 100644 --- a/tests/test_intake_runtime.py +++ b/tests/test_intake_runtime.py @@ -56,28 +56,51 @@ def test_medication_schedule_overlapping_and_delayed_completion(): ) -def test_diary_consecutive_days_and_baseline_do_not_include_bad_window(): - today = date(2026, 10, 3) - history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)} - for i in range(3): - history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"} - assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"} - history[today - timedelta(days=1)] = {} - assert diary_flags(history, today) == () - history[today]["death_thoughts"] = 1 - assert diary_flags(history, today) == ("suicide_assessment_now",) +def norm_and_week(norm: dict, week: dict, today=date(2026, 10, 30)): + """14 days of personal norm starting 27 days ago, then the last 7 days with other values.""" + start = today - timedelta(days=27) + history = {start + timedelta(days=i): dict(norm) for i in range(14)} + history.update({today - timedelta(days=i): dict(week) for i in range(7)}) + return history, today -def test_sleep_alternation_counts_but_threshold_is_strict(): +def test_weekly_mood_against_personal_norm(): + history, today = norm_and_week({"mood": 8}, {"mood": 5}) + assert "phq9_due_mood" in diary_flags(history, today) + history, today = norm_and_week({"mood": 8}, {"mood": 6}) + assert "phq9_due_mood" not in diary_flags(history, today) + # Inside the norm window there is no trend yet. + early = {date(2026, 10, 1) + timedelta(days=i): {"mood": 9 if i < 3 else 1} for i in range(10)} + assert "phq9_due_mood" not in diary_flags(early, date(2026, 10, 10)) + + +def test_module_trends_bring_full_scales_forward(): + ptsd = {"dpcl_1": 1, "dpcl_7": 1, "dpcl_10": 1, "dpcl_18": 1} + history, today = norm_and_week(ptsd, {k: 2 for k in ptsd}) + assert "pcl5_due_ptsd" in diary_flags(history, today) + history, today = norm_and_week({"dphq_4": 0}, {"dphq_4": 1}) + assert "phq9_due_depression" in diary_flags(history, today) + + +def test_sleep_alcohol_and_medication_flags(): today = date(2026, 10, 3) - h = { - today: {"nightmares": 3}, - today - timedelta(days=1): {"sleep_hours": 3.5}, - today - timedelta(days=2): {"sleep_hours": 3.5}, - } - assert "sleep_review" in diary_flags(h, today) - h[today - timedelta(days=1)]["sleep_hours"] = 4 - assert "sleep_review" not in diary_flags(h, today) + nights = {today - timedelta(days=i): {"sleep_hours": 7, "sleep_quality": 0} for i in range(3)} + nights[today - timedelta(days=1)] = {"sleep_hours": 3.5, "sleep_quality": 2} + assert "sleep_review" in diary_flags(nights, today) + nights[today - timedelta(days=1)]["sleep_hours"] = 4 + assert "sleep_review" not in diary_flags(nights, today) + + drinks = {today: {"alcohol_portions": 6}, today - timedelta(days=2): {"alcohol_portions": 7}} + assert set(diary_flags(drinks, today, medication_active=True)) >= {"alcohol_on_medication", "rus_audit_due_alcohol"} + assert "alcohol_on_medication" not in diary_flags(drinks, today) + + missed = {today - timedelta(days=i): {"medication": "no"} for i in range(3)} + assert "medication_contact" in diary_flags(missed, today) + week = {today - timedelta(days=i): {"medication": "yes" if i % 3 else "no"} for i in range(7)} + assert "adherence_review" in diary_flags(week, today) + assert diary_flags({today: {"medication": "no", "medication_reason": "side_effects"}}, today) == ( + "side_effects_review", + ) def test_total_threshold_and_zone_are_separate_decisions(): @@ -96,15 +119,60 @@ def test_total_threshold_and_zone_are_separate_decisions(): IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2}) -def test_module_rotation_never_drops_suicide_question(): +def test_diary_parts_and_module_rotation(): from app.core.intake_questions import diary_questions - first = { - q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100) - } - second = { - q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101) - } - assert "death_thoughts" in first & second - assert "nightmares" in first and "nightmares" not in second - assert "burden" in first and "burden" not in second + assert [q.id for q in diary_questions("morning", ["veteran", "depression"])] == [ + "sleep_hours", + "sleep_quality", + "alcohol_portions", + ] + evening = [q.id for q in diary_questions("evening", ["veteran", "depression"], day_index=0)] + assert evening == ["mood", "medication", "medication_reason", "dphq_1", "dphq_2", "dphq_3"] + [ + "dpcl_1", + "dpcl_7", + "dpcl_10", + "dpcl_18", + ] + assert [q.id for q in diary_questions("evening", ["depression"], day_index=5)][-3:] == [ + "dphq_7", + "dphq_8", + "dphq_9", + ] + assert [q.id for q in diary_questions("evening", [], day_index=1)] == ["mood", "medication", "medication_reason"] + + +def test_demo_policy_is_separate_and_marked(monkeypatch): + from app.config import get_settings + + monkeypatch.setenv("INTAKE_DEMO", "1") + monkeypatch.setattr(get_settings(), "demo_clock", True) + demo = load_policy() + assert demo.approval and "DEMO" in demo.approval.reviewer and demo.cssrs_positive_policy == "graded" + monkeypatch.delenv("INTAKE_DEMO") + assert load_policy().approval is None + + +def test_full_scale_change_against_the_start_of_observation(): + from app.core.interpretation import scale_interpretation + + def phq(total, baseline, previous=None): + totals = {"phq9": {"complete": True, "total": total}} + return scale_interpretation( + "phq9", totals, baseline=baseline, previous=previous, policy=load_policy(), retriage_delta=5 + )["codes"] + + assert phq(4, 16) == ["minimal", "remission"] + assert phq(8, 16) == ["mild", "response"] + assert phq(11, 16) == ["moderate", "improved"] + assert phq(15, 16) == ["moderately_severe", "stable"] + assert phq(21, 10, previous=16) == ["severe", "worse"] + pcl = scale_interpretation( + "pcl5", + {"pcl5": {"complete": True, "total": 27}}, + baseline=45, + previous=40, + policy=load_policy(), + retriage_delta=10, + ) + assert pcl["codes"] == ["below", "response"] diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..e4cd754 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.vite/ diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..11961d5 --- /dev/null +++ b/web/README.md @@ -0,0 +1,71 @@ +# Веб-приложение пациента + +Ещё один канал к тому же движку диалога, что и Telegram-бот (ADR-004): согласие, онбординг, шкалы, чек-ины и +кризисный скрипт приходят из `app/dialog` через `/api/v1/dialog/{token}/...`. Бот продолжает работать параллельно. + +## Запуск + +```bash +make up # или: uv run uvicorn app.main:app --port 8000 +cd web && npm install +npm run dev # http://localhost:5173 — /api проксируется на :8000 +``` + +Интерфейс открывается на **:5173**; на :8000 работает только API (`/docs`). + +## Вход + +| Способ | Что происходит | API | +|---|---|---| +| ЕМИАС (демо) | одноразовый код от врача → токен пациента, привязка к врачу; или синтетический демо-пациент | `POST /auth/emias`, `POST /auth/emias/demo` | +| Анонимно | случайный токен, в identity-БД ничего не пишется, врач не привязан | `POST /auth/anonymous` | + +Реальная авторизация ЕМИАС/ЕСИА подключается как `IdentityProvider` (`app/integrations/emias_mock.py`) без +изменений во фронтенде. + +## Структура (под перенос в мобильное приложение) + +``` +src/theme/tokens.css цвета, шрифт, отступы — единственный источник; три цвета = три направления сервиса +src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок) +src/api/ типы-зеркала контрактов бэкенда и клиент +src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain +src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts) +src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4), + «Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1 +``` + +### Как устроены экраны + +Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его +как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст, +напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда. + +Данные для «Сегодня» и «Лекарства» — `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/index.html b/web/index.html new file mode 100644 index 0000000..379a015 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Сопровождение пациента + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..3dfda63 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1802 @@ +{ + "name": "ptsr-patient-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ptsr-patient-web", + "version": "0.1.0", + "dependencies": { + "@fontsource/golos-text": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource/golos-text": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/golos-text/-/golos-text-5.3.0.tgz", + "integrity": "sha512-R+gM4qOZSrge4TtEGps0zTpU8X4e7e32L+3yy0J/2LnVB/IERlidEpXPWSZVGzTaL5QnPRVmoebRXSuVtjUIVg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", + "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", + "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", + "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", + "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", + "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", + "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", + "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", + "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", + "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", + "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", + "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", + "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", + "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", + "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", + "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", + "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", + "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", + "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", + "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", + "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", + "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", + "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", + "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", + "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", + "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", + "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.0", + "@rollup/rollup-android-arm64": "4.64.0", + "@rollup/rollup-darwin-arm64": "4.64.0", + "@rollup/rollup-darwin-x64": "4.64.0", + "@rollup/rollup-freebsd-arm64": "4.64.0", + "@rollup/rollup-freebsd-x64": "4.64.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", + "@rollup/rollup-linux-arm-musleabihf": "4.64.0", + "@rollup/rollup-linux-arm64-gnu": "4.64.0", + "@rollup/rollup-linux-arm64-musl": "4.64.0", + "@rollup/rollup-linux-loong64-gnu": "4.64.0", + "@rollup/rollup-linux-loong64-musl": "4.64.0", + "@rollup/rollup-linux-ppc64-gnu": "4.64.0", + "@rollup/rollup-linux-ppc64-musl": "4.64.0", + "@rollup/rollup-linux-riscv64-gnu": "4.64.0", + "@rollup/rollup-linux-riscv64-musl": "4.64.0", + "@rollup/rollup-linux-s390x-gnu": "4.64.0", + "@rollup/rollup-linux-x64-gnu": "4.64.0", + "@rollup/rollup-linux-x64-musl": "4.64.0", + "@rollup/rollup-openbsd-x64": "4.64.0", + "@rollup/rollup-openharmony-arm64": "4.64.0", + "@rollup/rollup-win32-arm64-msvc": "4.64.0", + "@rollup/rollup-win32-ia32-msvc": "4.64.0", + "@rollup/rollup-win32-x64-gnu": "4.64.0", + "@rollup/rollup-win32-x64-msvc": "4.64.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..66b24ad --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "ptsr-patient-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "@fontsource/golos-text": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.3", + "vite": "^5.4.10" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..7830f3e --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..3400cd7 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,57 @@ +import type { + AuthResult, + Catalog, + CrisisResource, + DemoPersona, + DialogEvent, + Focus, + MedsReference, + Reply, + Results, + 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"; + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +async function request(path: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }); + } catch { + throw new ApiError(0, "network"); + } + if (!res.ok) throw new ApiError(res.status, res.statusText); + return (await res.json()) as T; +} + +const post = (path: string, body?: unknown) => + request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }); + +export const api = { + signInEmias: (code: string) => post("/auth/emias", { code }), + signInEmiasDemo: (personaToken: string) => post("/auth/emias/demo", { persona_token: personaToken }), + signInAnonymous: (focus: Focus) => post("/auth/anonymous", { focus }), + demoPersonas: () => request("/patient/demo-personas"), + crisisResources: () => request("/crisis/resources"), + catalog: () => request("/instruments/catalog"), + medsReference: () => request("/meds/reference"), + today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`), + results: (token: string) => request(`/me/${encodeURIComponent(token)}/results`), + sendEvent: async (token: string, event: DialogEvent) => + (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, + outbox: async (token: string) => + (await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies, +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..de5c3e5 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,182 @@ +// Mirrors of the backend contracts (app/dialog/contracts.py, app/api/schemas.py, app/api/patient_web.py). +// Keep in sync by hand: the frozen contracts change only through a [contract] PR. + +export interface DialogButton { + label: string; + data: string; +} + +export type ReplyKind = "normal" | "question" | "crisis" | "safety_plan" | "info"; + +export interface Reply { + text: string; + buttons: DialogButton[][]; + kind: ReplyKind; +} + +export type DialogEvent = + | { type: "start"; params?: Record } + | { type: "text"; text: string } + | { type: "button"; button: string }; + +export type AuthMode = "emias" | "anonymous"; + +/** What an anonymous person suspects; the backend maps it to a clinical module. */ +export type Focus = "depression" | "ptsd"; + +export interface AuthResult { + token: string; + mode: AuthMode; + display_name: string | null; +} + +export interface CrisisResource { + name: string; + phone: string; + note: string; +} + +export interface DemoPersona { + token: string; + display_name: string; + therapy_day: number; + scenario: string | null; +} + +export type DoseStatus = "taken" | "missed" | "skipped" | "pending"; + +export interface TodayDose { + id: number; + drug: string; + dose: string; + time: string; + 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; + diary_next: "morning" | "evening" | null; +} + +// app/api/patient_web.py: /me/{token}/results — preliminary conclusion of stage 1 and the questionnaire log. +export interface Conclusion { + day: string; + title: string; + lines: string[]; + findings: { code: string; title: string }[]; + modules: string[]; + disclaimer: string; +} + +export interface HistoryItem { + id: string; + kind: string; + kind_title: string; + status: string; + status_title: string; + day: string; + started_at: string; + completed_at: string | null; + title: string | null; + lines: string[]; +} + +export interface Results { + conclusion: Conclusion | null; + history: HistoryItem[]; +} + +export interface TodayPlan { + today: string; + now: string; + has_doctor: boolean; + checkin_done: boolean; + 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/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..1fde105 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,191 @@ +import type { ReactNode } from "react"; +import { Link, NavLink } from "react-router-dom"; + +import { ru } from "../content/ru"; +import { useDialog } from "../dialog/DialogContext"; +import { HotlineCard, useHelp } from "./HelpSheet"; +import { Icon, LogoMark, type IconName } from "./Icon"; + +/* + * Layout from docs/mock (series 2). + * phone: content → «Экстренная помощь» block → bottom tab bar (signed in) + * desktop: navy sidebar | content | right rail (optional `aside` + emergency panel) + * bare: one centred column on every width (questionnaires) + */ + +const TABS: { to: string; label: string; icon: IconName }[] = [ + { to: "/today", label: ru.nav.today, icon: "home" }, + { to: "/meds", label: ru.nav.meds, icon: "pill" }, + { to: "/doctor", label: ru.nav.doctor, icon: "doctor" }, + { to: "/profile", label: ru.nav.profile, icon: "user" }, +]; + +/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */ +export function EmergencyBlock() { + const { resources, open } = useHelp(); + return ( +
+ +
+ {resources.slice(0, 2).map((r) => ( + + ))} +
+
+ ); +} + +function Sidebar({ signedIn }: { signedIn: boolean }) { + const { open } = useHelp(); + const { unread } = useDialog(); + return ( + + ); +} + +function BottomNav() { + return ( + + ); +} + +function Rail({ aside }: { aside?: ReactNode }) { + const { resources, open } = useHelp(); + return ( + + ); +} + +export function AppShell({ + children, + signedIn = true, + aside, + focus = false, + sos = true, + bare = false, +}: { + children: ReactNode; + signedIn?: boolean; + aside?: ReactNode; + /** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */ + focus?: boolean; + /** The crisis screen shows the hotlines itself, so it drops the bottom block. */ + sos?: boolean; + /** Questionnaires: no sidebar, rail or tab bar — one centred column, so nothing distracts from the answers. */ + bare?: boolean; +}) { + if (bare) + return ( +
+
+
+ {children} + {sos && } +
+
+
+ ); + return ( +
+ +
+
+ {children} + {sos && } +
+ +
+ {signedIn && !focus && } +
+ ); +} + +export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) { + const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert"; + return ( +
+ +
{children}
+
+ ); +} + +export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) { + return ( +
+ {onBack && ( + + )} + {title && {title}} + {children} +
+ ); +} diff --git a/web/src/components/HelpSheet.tsx b/web/src/components/HelpSheet.tsx new file mode 100644 index 0000000..7c82bda --- /dev/null +++ b/web/src/components/HelpSheet.tsx @@ -0,0 +1,115 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; + +import { api } from "../api/client"; +import type { CrisisResource } from "../api/types"; +import { ru } from "../content/ru"; +import { Icon } from "./Icon"; + +// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»). +const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }]; + +export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`; + +interface HelpApi { + resources: CrisisResource[]; + open: () => void; +} + +const Ctx = createContext({ resources: FALLBACK, open: () => undefined }); +export const useHelp = () => useContext(Ctx); + +/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */ +export function HelpProvider({ children }: { children: ReactNode }) { + const [resources, setResources] = useState(FALLBACK); + const [isOpen, setOpen] = useState(false); + + useEffect(() => { + api + .crisisResources() + .then((r) => r.length && setResources(r)) + .catch(() => undefined); + }, []); + + const open = useCallback(() => setOpen(true), []); + return ( + + {children} + setOpen(false)} resources={resources} /> + + ); +} + +export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) { + const emergency = r.phone.replace(/\D/g, "").length <= 3; + return ( + + + + + + {emergency ? ( + <> + {r.phone} + {r.name} + + ) : ( + <> + {r.name} + {r.phone} + + )} + {r.note && size === "lg" && {r.note}} + + {size === "lg" && } + + ); +} + +function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) { + const ref = useRef(null); + + useEffect(() => { + const dialog = ref.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + }, [open]); + + return ( + e.target === ref.current && onClose()} + > +
+
+ +

+ {ru.help.title} +

+
+
+ + + + + +

{ru.help.lead}

+
+
+ {resources.map((r) => ( + + ))} +
+

+ + {ru.help.alwaysNote} +

+
+
+ ); +} diff --git a/web/src/components/Icon.tsx b/web/src/components/Icon.tsx new file mode 100644 index 0000000..cf90800 --- /dev/null +++ b/web/src/components/Icon.tsx @@ -0,0 +1,170 @@ +// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers. + +const paths = { + home: , + pill: ( + <> + + + + ), + doctor: ( + <> + + + + + ), + user: ( + <> + + + + ), + bell: ( + <> + + + + ), + chevronRight: , + chevronDown: , + arrowLeft: , + close: , + check: , + x: , + info: ( + <> + + + + ), + alert: ( + <> + + + + ), + phone: ( + + ), + headset: ( + <> + + + + + ), + calendar: ( + <> + + + + ), + clock: ( + <> + + + + ), + pin: ( + <> + + + + ), + smile: ( + <> + + + + ), + doc: ( + <> + + + + ), + docPlus: ( + <> + + + + ), + shield: ( + <> + + + + ), + lock: ( + <> + + + + ), + edit: , + trash: , + logout: , + send: , + heart: , + lungs: ( + <> + + + + ), + question: ( + <> + + + + ), + message: , + plus: , + clipboard: ( + <> + + + + ), + book: ( + <> + + + + ), + pause: , +} as const; + +export type IconName = keyof typeof paths; + +export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) { + return ( + + {paths[name]} + + ); +} + +export function LogoMark({ size = 32 }: { size?: number }) { + return ( + + ); +} diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx new file mode 100644 index 0000000..3927ac4 --- /dev/null +++ b/web/src/components/Toast.tsx @@ -0,0 +1,55 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react"; + +import { Icon } from "./Icon"; + +/* + * Short confirmations («отметил приём», «дневник сохранён») as a toast in the top-right corner instead of a separate + * confirmation page: the person lands on the home screen right away and still sees what happened. + */ + +interface Toast { + id: number; + text: string; +} + +const Ctx = createContext<(text: string) => void>(() => undefined); +const LIFETIME_MS = 6000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const next = useRef(0); + + const close = useCallback((id: number) => setToasts((all) => all.filter((t) => t.id !== id)), []); + const show = useCallback( + (text: string) => { + const id = ++next.current; + setToasts((all) => [...all.slice(-2), { id, text }]); + window.setTimeout(() => close(id), LIFETIME_MS); + }, + [close], + ); + + const value = useMemo(() => show, [show]); + return ( + + {children} +
+ {toasts.map((t) => ( +
+ +

{t.text}

+ +
+ ))} +
+
+ ); +} + +export function useToast(): (text: string) => void { + return useContext(Ctx); +} diff --git a/web/src/content/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 new file mode 100644 index 0000000..44e0c74 --- /dev/null +++ b/web/src/content/ru.ts @@ -0,0 +1,252 @@ +/* + * All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales, + * reminders — comes from the engine (config/texts/) and is NOT duplicated here. + */ +export const ru = { + product: { + name: "Сопровождение пациента", + tagline: "между визитами к врачу", + }, + nav: { + today: "Сегодня", + meds: "Лекарства", + doctor: "Врач", + profile: "Профиль", + messages: "Сообщения", + signIn: "Вход", + help: "Помощь", + }, + help: { + block: "Экстренная помощь", + title: "Экстренная помощь", + lead: "Если вам нужна срочная помощь, позвоните сразу.", + call: "Позвонить", + close: "Закрыть", + alwaysNote: "Вы не одни. Помощь доступна круглосуточно.", + fallbackName: "Единый номер экстренных служб", + fallbackNote: "", + }, + common: { + back: "Назад", + next: "Далее", + skip: "Пропустить", + send: "Отправить", + retry: "Повторить", + toToday: "Вернуться на сегодня", + recommended: "Рекомендуем", + networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.", + }, + welcome: { + title: "Вход", + emias: { + title: "Войти через ЕМИАС", + perks: [ + "Назначения врача подгружаются автоматически", + "Врач видит ответы и получает сигнал при ухудшении", + "История доступна с любого устройства", + ], + }, + or: "или", + anonymous: { + title: "Войти анонимно", + text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.", + }, + disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.", + }, + emias: { + title: "Введите код от врача", + lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.", + demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.", + codeLabel: "Код подключения", + codePlaceholder: "Код из памятки", + submit: "Продолжить", + submitting: "Проверяем код…", + codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.", + or: "или", + demoTitle: "Демо-пациенты", + demoLead: "Нет кода? Войдите под синтетической учётной записью.", + demoDay: (day: number) => `${day}-й день терапии`, + }, + anonymous: { + title: "Что вы хотите проверить?", + note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.", + focus: { + depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" }, + ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" }, + }, + submitting: "Создаём профиль…", + }, + today: { + title: "Сегодня", + takeMed: "Принять лекарство", + now: "Сейчас", + checkin: "Отметка самочувствия", + checkinHint: "меньше минуты", + done: "Уже выполнено", + medTaken: "Лекарство", + medTakenAt: (time: string) => `принято, ${time}`, + checkinDone: "Самочувствие отмечено", + allDoneTitle: "На сегодня всё сделано", + allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`, + allDoneNoMeds: "Следующая отметка самочувствия — завтра", + viewMeds: "Посмотреть лекарства", + visit: "Следующий визит к врачу", + medQuestion: "Вопрос о лекарстве", + medQuestionHint: "Ответ по официальной инструкции", + messageDoctor: "Сообщение врачу", + messageDoctorHint: "Врач ответит в рабочее время", + week: "Приём на этой неделе", + weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"), + noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.", + newMessage: "Новое сообщение", + open: "Открыть", + continue: "Продолжить", + intakeStart: "Первичный опрос", + intakeStartHint: "15–20 минут, можно прерваться и продолжить позже", + intakeResume: "Продолжить первичный опрос", + intakeResumeHint: "Ответы сохранены, можно продолжить", + intakeReview: + "Опрос приостановлен: продолжить можно после оценки специалиста, врач уже получил уведомление. Если нужна помощь сейчас — 112.", + intakeDone: "Первичный опрос пройден", + diary: "Дневник самочувствия", + diaryHint: "2–3 минуты: настроение, сон, лекарства", + diaryPart: { morning: "Дневник — утро", evening: "Дневник — вечер" }, + diaryPartHint: { morning: "3 вопроса: сон и алкоголь", evening: "Настроение, лекарства и модули по результату опроса" }, + results: "Мой результат", + diaryDone: "Дневник заполнен", + therapyDay: (day: number) => `День ${day} лечения`, + effectHint: "Когда ждать эффекта и что важно знать", + }, + meds: { + title: "Моя схема приёма", + lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.", + byDoctor: "По назначению врача", + tablet: "1 приём", + week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"], + onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.", + empty: "Назначения появятся здесь после входа через ЕМИАС.", + question: "Вопрос о лекарстве", + reminderTitle: "Время принять препарат", + reminderLead: "По назначению врача", + 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: "Врач", + visit: "Следующий визит", + visitNone: "Дата следующего визита пока не назначена", + attending: "Ваш лечащий врач", + write: "Написать врачу", + writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.", + earlier: "Записаться к врачу раньше", + plan: "Мой план безопасности", + noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.", + }, + profile: { + title: "Профиль", + signInMode: "Способ входа", + emias: "ЕМИАС", + anonymous: "Анонимно", + connectEmias: "Подключить ЕМИАС", + data: "Мои данные", + revoke: "Отозвать согласие", + revokeTitle: "Отозвать согласие?", + revokeText: "Ваши ответы больше не будут обрабатываться в приложении.", + revokeConfirm: "Да, отозвать", + cancel: "Отмена", + wipe: "Удалить данные с устройства", + wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.", + logout: "Выйти", + messages: "Сообщения", + result: "Предварительный результат", + resultNone: "Появится после первичного опроса.", + history: "История опросов", + historySub: "Все опросы и дневники с датами и результатами", + }, + results: { + title: "Мои результаты", + conclusion: "Предварительный результат", + conclusionDay: (d: string) => `Первичный опрос от ${d}`, + modules: { depression: "Модуль «Депрессия»", veteran: "Модуль «ПТСР»" }, + history: "История опросов", + empty: "Здесь появятся пройденные опросы.", + noResult: "Пройдите первичный опрос — после него здесь будет результат.", + toIntake: "Пройти первичный опрос", + }, + flow: { + question: (n: number, total: number) => `Вопрос ${n} из ${total}`, + period: "За последний месяц", + textPlaceholder: "Напишите несколько слов", + sending: "Отправляем…", + doneTitle: "Готово", + resultTitle: "Результат", + sendEmpty: "Отправить без комментария", + doneLead: "Врач увидит отметку.", + breathe: "Вдох на 4 — выдох на 6", + breatheIn: "Вдох", + breatheOut: "Выдох", + openPlan: "Открыть мой план безопасности", + supportTitle: "Вы не одни", + exit: "К главной", + planTitle: "Мой план безопасности", + consentTitle: "Согласие на обработку данных о здоровье", + sections: { + safety: "Безопасность", + mood: "Настроение", + trauma: "Пережитый опыт", + alcohol: "Алкоголь", + about: "О вас", + diary: "Дневник", + scale: "Опросник", + }, + questionN: (n: number) => `Вопрос ${n}`, + recall: (period: string) => `Период: ${period}`, + hoursLabel: "Сколько часов", + hoursUnit: "ч", + hoursHint: "От 0 до 24, можно с половиной: 6,5", + portionsLabel: "Сколько порций", + portionsUnit: "порц.", + portionsHint: "Если не пили — 0. Можно дробное: 1,5", + drinks: "Таблица стандартных порций", + drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.", + noticeTitle: "Опрос приостановлен", + }, + messages: { + title: "Сообщения", + inputLabel: "Сообщение", + inputPlaceholder: "Напишите сообщение", + you: "Вы", + empty: "Здесь будут сообщения помощника.", + }, + footer: { + law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).", + prototype: "Прототип для хакатона MedITron. Используются только синтетические данные.", + }, +} as const; diff --git a/web/src/dialog/DialogContext.tsx b/web/src/dialog/DialogContext.tsx new file mode 100644 index 0000000..13d4793 --- /dev/null +++ b/web/src/dialog/DialogContext.tsx @@ -0,0 +1,115 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +import { api } from "../api/client"; +import type { DialogEvent, Reply } from "../api/types"; +import { useSession } from "../session/SessionContext"; +import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store"; + +/* + * One conversation with the dialog engine per signed-in patient, shared by every screen: + * focus screens render `pending` (the latest unanswered batch), «Сообщения» renders `entries`. + * The engine owns the logic (consent, onboarding, scales, check-ins, meds, crisis) — exactly as in Telegram. + */ + +const OUTBOX_POLL_MS = 6000; + +let seq = 0; +const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`; +const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply })); + +interface DialogApi extends DialogSnapshot { + busy: boolean; + error: boolean; + started: boolean; + /** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */ + send: (event: DialogEvent, echo?: string) => Promise; + start: () => Promise; + markRead: () => void; +} + +const Ctx = createContext(null); +const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 }; + +export function DialogProvider({ children }: { children: ReactNode }) { + const { session } = useSession(); + const token = session?.token ?? null; + const [owner, setOwner] = useState(token); + const [snap, setSnap] = useState(() => (token ? sessionStore.loadDialog(token) : EMPTY)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const tokenRef = useRef(token); + tokenRef.current = token; + + // Switching accounts: reset to that token's snapshot during render, so it is never saved under another token. + if (owner !== token) { + setOwner(token); + setSnap(token ? sessionStore.loadDialog(token) : EMPTY); + setError(false); + } + + useEffect(() => { + if (token && owner === token) sessionStore.saveDialog(token, snap); + }, [token, owner, snap]); + + const send = useCallback( + async (event: DialogEvent, echo?: string) => { + const t = tokenRef.current; + if (!t) return false; + setBusy(true); + setError(false); + try { + const replies = await api.sendEvent(t, event); + setSnap((prev) => ({ + entries: [ + ...prev.entries, + ...(echo ? [{ id: newId(), from: "patient" as const, text: echo }] : []), + ...asEntries(replies), + ], + pending: replies, + unread: 0, + })); + return true; + } catch { + setError(true); + return false; + } finally { + setBusy(false); + } + }, + [], + ); + + const start = useCallback(() => send({ type: "start" }), [send]); + const markRead = useCallback(() => setSnap((prev) => (prev.unread ? { ...prev, unread: 0 } : prev)), []); + + // System-initiated messages (reminders, check-ins, scales) arrive through the outbox. + useEffect(() => { + if (!token) return; + const timer = window.setInterval(() => { + api + .outbox(token) + .then((replies) => { + if (!replies.length || tokenRef.current !== token) return; + setSnap((prev) => ({ + entries: [...prev.entries, ...asEntries(replies)], + pending: replies, + unread: prev.unread + replies.length, + })); + }) + .catch(() => undefined); + }, OUTBOX_POLL_MS); + return () => window.clearInterval(timer); + }, [token]); + + const value = useMemo( + () => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }), + [snap, busy, error, send, start, markRead], + ); + return {children}; +} + +export function useDialog(): DialogApi { + const value = useContext(Ctx); + if (!value) throw new Error("useDialog outside DialogProvider"); + return value; +} diff --git a/web/src/dialog/step.ts b/web/src/dialog/step.ts new file mode 100644 index 0000000..a291a6a --- /dev/null +++ b/web/src/dialog/step.ts @@ -0,0 +1,189 @@ +import type { DialogButton, Reply } from "../api/types"; + +/* + * Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is + * asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen. + */ + +export type Step = + | { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over + | { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home + | { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] } + | { kind: "crisis"; text: string; buttons: DialogButton[] } + | { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] } + | { 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; 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" | "portions" | "text"; + anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item + asOf?: string; // ISO date the recall period counts from + skip?: DialogButton; + help?: DialogButton; + pause?: DialogButton; +} + +export interface Progress { + n: number; + total: number; +} + +// config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own. +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(); +const paragraphs = (text: string) => + text + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + +/** Splits a reply into a title (the actual question) and lead paragraphs above it. */ +function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } { + const parts = texts.flatMap(paragraphs); + const last = parts.pop() ?? ""; + const m = SCALE_ITEM.exec(last); + if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } }; + // A long last paragraph is body text, not a heading. + if (last.length > 220) return { lead: [...parts, last], title: "" }; + 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" + : // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours. + /порци/i.test(title) + ? "portions" + : "hours"; + return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause }; +} + +export function classify(batch: Reply[]): Step | null { + if (batch.length === 0) return null; + + const crisis = batch.find((r) => r.kind === "crisis"); + if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) }; + + if (batch.some(isMenu)) { + const notes = batch + .filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT) + .map((r) => r.text.trim()) + .filter((t) => t && t !== MENU_PROMPT); + return { kind: "idle", notes }; + } + + const plan = batch.find((r) => r.kind === "safety_plan"); + if (plan) { + const [title, ...rest] = plan.text.split("\n").map((l) => l.trim()); + const lead = batch.filter((r) => r !== plan).map((r) => r.text); + return { + kind: "plan", + lead, + title, + lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")), + buttons: batch.flatMap(flat), + }; + } + + // The question is the last reply with buttons; everything before it is context. + const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true); + const texts = batch.map((r) => r.text); + if (qIndex === -1) { + // Without buttons the engine waits for free text only when it marks the reply as a question; anything else + // («отметил приём») is a confirmation. + if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) }; + const { lead, title } = titled(texts); + return { kind: "text", lead, title }; + } + 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]; + + if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options }; + if (options.every((b) => b.data.endsWith(":skip"))) + return { kind: "text", lead, title: head.title, skip: options[0] }; + if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim()))) + 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 }; + 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. */ +export function lines(text: string): { list: boolean; text: string }[] { + const out: { list: boolean; text: string }[] = []; + for (const raw of text.split("\n")) { + const l = raw.trim(); + if (!l) continue; + if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") }); + else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text)) + out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` }; + else out.push({ list: false, text: l }); + } + return out; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..0b98700 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,69 @@ +import "@fontsource/golos-text/400.css"; +import "@fontsource/golos-text/500.css"; +import "@fontsource/golos-text/600.css"; +import "@fontsource/golos-text/700.css"; +import "./theme/tokens.css"; +import "./theme/global.css"; + +import { StrictMode, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; + +import { HelpProvider } from "./components/HelpSheet"; +import { ToastProvider } from "./components/Toast"; +import { DialogProvider } from "./dialog/DialogContext"; +import { AnonymousSignIn } from "./screens/AnonymousSignIn"; +import { Doctor } from "./screens/Doctor"; +import { DrugInfo } from "./screens/DrugInfo"; +import { EmiasSignIn } from "./screens/EmiasSignIn"; +import { Flow } from "./screens/Flow"; +import { DoseReminder, Meds } from "./screens/Meds"; +import { Messages } from "./screens/Messages"; +import { Profile } from "./screens/Profile"; +import { Results } from "./screens/Results"; +import { Today } from "./screens/Today"; +import { Welcome } from "./screens/Welcome"; +import { SessionProvider, useSession } from "./session/SessionContext"; + +function Private({ children }: { children: ReactNode }) { + const { session } = useSession(); + return session ? <>{children} : ; +} + +// Hash routing works the same on a static host, file:// and inside the Capacitor WebView. +function AppRoutes() { + const { session } = useSession(); + return ( + + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +createRoot(document.getElementById("root")!).render( + + + + + + + + + + + + + , +); diff --git a/web/src/screens/AnonymousSignIn.tsx b/web/src/screens/AnonymousSignIn.tsx new file mode 100644 index 0000000..097248f --- /dev/null +++ b/web/src/screens/AnonymousSignIn.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "../api/client"; +import type { Focus } from "../api/types"; +import { AppShell, Notice, TopBar } from "../components/AppShell"; +import { Icon, type IconName } from "../components/Icon"; +import { ru } from "../content/ru"; +import { useSession } from "../session/SessionContext"; + +const FOCUS: { id: Focus; icon: IconName }[] = [ + { id: "depression", icon: "smile" }, + { id: "ptsd", icon: "heart" }, +]; + +// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос +export function AnonymousSignIn() { + const t = ru.anonymous; + const navigate = useNavigate(); + const { signIn } = useSession(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + + const start = async (focus: Focus) => { + setBusy(true); + setError(false); + try { + signIn(await api.signInAnonymous(focus)); + navigate("/flow", { replace: true }); + } catch { + setError(true); + setBusy(false); + } + }; + + return ( + +
+ navigate("/")} /> +
+

{t.title}

+ +
+ {FOCUS.map(({ id, icon }) => ( + + ))} +
+ + {error && {ru.common.networkError}} +

{busy ? t.submitting : t.note}

+
+
+
+ ); +} diff --git a/web/src/screens/Doctor.tsx b/web/src/screens/Doctor.tsx new file mode 100644 index 0000000..4417812 --- /dev/null +++ b/web/src/screens/Doctor.tsx @@ -0,0 +1,73 @@ +import { useNavigate } from "react-router-dom"; + +import { AppShell, Notice } from "../components/AppShell"; +import { Icon, type IconName } from "../components/Icon"; +import { ru } from "../content/ru"; +import { useDialog } from "../dialog/DialogContext"; +import { humanDate, useToday } from "./useToday"; + +// F1 / F2 · Врач: следующий визит и связь с врачом (действия выполняет движок диалога) +export function Doctor() { + const t = ru.doctor; + const navigate = useNavigate(); + const dialog = useDialog(); + const { plan } = useToday(); + + const go = (button: string, label: string) => + void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow")); + + const actions: { button: string; label: string; icon: IconName }[] = [ + { button: "menu:contact_doctor", label: t.write, icon: "message" }, + { button: "menu:visit", label: t.earlier, icon: "calendar" }, + { button: "safety_plan", label: t.plan, icon: "shield" }, + ]; + + return ( + +
+
+

{t.title}

+
+ + {plan && !plan.has_doctor ? ( + {t.noDoctor} + ) : ( +
+
+
+ + + + + {t.visit} + {plan?.next_visit ? humanDate(plan.next_visit) : t.visitNone} + +
+
+ + + + {t.attending} +
+
+ +
+ {actions.map((a) => ( + + ))} +
+ {t.writeNote} +
+ )} +
+
+ ); +} 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/EmiasSignIn.tsx b/web/src/screens/EmiasSignIn.tsx new file mode 100644 index 0000000..1e20fc1 --- /dev/null +++ b/web/src/screens/EmiasSignIn.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; + +import { ApiError, api } from "../api/client"; +import type { AuthResult, DemoPersona } from "../api/types"; +import { AppShell, Notice, TopBar } from "../components/AppShell"; +import { Icon } from "../components/Icon"; +import { ru } from "../content/ru"; +import { useSession } from "../session/SessionContext"; + +// A2 · Вход через ЕМИАС (демо: одноразовый код от врача или синтетический пациент) +export function EmiasSignIn() { + const t = ru.emias; + const navigate = useNavigate(); + const { signIn } = useSession(); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [personas, setPersonas] = useState(null); + + useEffect(() => { + api + .demoPersonas() + .then(setPersonas) + .catch(() => setPersonas([])); + }, []); + + const finish = (auth: AuthResult) => { + signIn(auth); + navigate("/flow", { replace: true }); + }; + + const fail = (e: unknown) => { + setError(e instanceof ApiError && e.status === 401 ? t.codeError : ru.common.networkError); + setBusy(false); + }; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!code.trim() || busy) return; + setBusy(true); + setError(null); + try { + finish(await api.signInEmias(code.trim())); + } catch (err) { + fail(err); + } + }; + + const demo = async (persona: DemoPersona) => { + setBusy(true); + setError(null); + try { + finish(await api.signInEmiasDemo(persona.token)); + } catch (err) { + fail(err); + } + }; + + return ( + +
+ navigate("/")} /> +
+
+
+ + + +

{t.title}

+

{t.lead}

+
+ {t.demoNotice} +
+
+
+ + setCode(e.target.value)} + placeholder={t.codePlaceholder} + autoComplete="one-time-code" + autoCapitalize="characters" + spellCheck={false} + maxLength={32} + aria-invalid={error ? true : undefined} + aria-describedby={error ? "code-error" : undefined} + /> + {error && ( + + )} + +
+ +
+ {t.or} +
+ +
+

+ {t.demoTitle} +

+

{t.demoLead}

+ {personas?.map((p) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/web/src/screens/Flow.tsx b/web/src/screens/Flow.tsx new file mode 100644 index 0000000..50d15fb --- /dev/null +++ b/web/src/screens/Flow.tsx @@ -0,0 +1,731 @@ +import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; + +import type { Catalog, DialogButton } from "../api/types"; +import { AppShell, Notice, TopBar } from "../components/AppShell"; +import { HotlineCard, telHref, useHelp } from "../components/HelpSheet"; +import { useToast } from "../components/Toast"; +import { Icon, type IconName } from "../components/Icon"; +import { useCatalog } from "../content/reference"; +import { ru } from "../content/ru"; +import { useDialog } from "../dialog/DialogContext"; +import { classify, lines, type Step, type Survey } from "../dialog/step"; + +/* + * 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), + * versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник). + */ + +const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g; + +function RichText({ text }: { text: string }) { + return ( + <> + {text.split(PHONE_RE).map((part, i) => + i % 2 === 1 ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + +function Body({ texts }: { texts: string[] }) { + if (!texts.length) return null; + return ( +
+ {texts.map((t, i) => { + const ls = lines(t); + return ( +
+ {ls.map((l, j) => + l.list ? ( +
    +
  • + +
  • +
+ ) : ( +

+ +

+ ), + )} +
+ ); + })} +
+ ); +} + +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 ( +
+ + + + {ru.flow.question(n, total)} +
+ ); +} + +// 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}
+
+

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

+
+ ); +} + +function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) { + return ( +
+ {icon && ( + + + + )} + {title &&

{title}

} + +
+ ); +} + +function Choice({ + options, + busy, + onSubmit, +}: { + options: DialogButton[]; + busy: boolean; + onSubmit: (b: DialogButton) => void; +}) { + const [picked, setPicked] = useState(null); + return ( + <> +
+ {options.map((b) => { + const on = picked?.data === b.data; + return ( + + ); + })} +
+ + + ); +} + +function NumberScale({ + options, + busy, + onSubmit, + anchors, +}: { + options: DialogButton[]; + busy: boolean; + onSubmit: (b: DialogButton) => void; + anchors?: string[]; +}) { + const [picked, setPicked] = useState(null); + return ( + <> +
+ + {picked ? picked.label : "–"} + +
+ {options.map((b) => ( + + ))} +
+ {anchors && anchors.length === 2 && ( +
+ {anchors[0]} + {anchors[1]} +
+ )} +
+ + + ); +} + +// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py). +const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" }; + +function FreeText({ + skip, + busy, + onSend, + onSkip, + onExit, +}: { + skip?: DialogButton; + busy: boolean; + onSend: (text: string) => void; + onSkip: (b: DialogButton) => void; + onExit: () => void; +}) { + const [text, setText] = useState(""); + return ( + <> +
+ +