release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
82 changed files with 9945 additions and 153 deletions
Showing only changes of commit 0106aefc33 - Show all commits
+5
View File
@@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS=
GIGACHAT_SCOPE=GIGACHAT_API_PERS GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))" # --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY= PSEUDO_KEY=
+18
View File
@@ -0,0 +1,18 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme]
base = "light"
primaryColor = "#2A78D6"
backgroundColor = "#F6F7F9"
secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24"
borderColor = "#E3E6EA"
baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004).
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif"
headingFontWeights = 700
[client]
toolbarMode = "minimal"
[browser]
gatherUsageStats = false
+89
View File
@@ -0,0 +1,89 @@
"""Demo-console helpers (#12, ADR-005): scripted personas, reset, connect links, outbox access.
Synthetic data only. Scenario files live in `data/synthetic/<slug>.jsonl` (#18); a patient is a scripted persona
when the transliterated first word of the display name equals the slug ("Андрей С." → `andrey.jsonl`).
"""
import inspect
import logging
from importlib import import_module, util
from pathlib import Path
from app.config import get_settings
from app.integrations.base import MemoryOutbox, Outbox, integrations
log = logging.getLogger(__name__)
SEED_MODULE = "scripts.seed_demo"
CONSOLE_PATH = "/console"
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def scenario_files(data_dir: Path | None = None) -> dict[str, str]:
"""slug → scenario path relative to the project root, e.g. {"andrey": "data/synthetic/andrey.jsonl"}."""
root = data_dir or get_settings().data_dir
folder = root / "synthetic"
if not folder.is_dir():
return {}
return {f.stem: f"data/synthetic/{f.name}" for f in sorted(folder.glob("*.jsonl"))}
async def bot_username() -> str | None:
"""Username of the running Telegram bot (deep links); None when the bot is not started (no TG_BOT_TOKEN)."""
from app.channels import telegram
bot = getattr(telegram, "_bot", None)
if bot is None:
return None
try:
me = await bot.me() # aiogram caches getMe
except Exception as exc: # noqa: BLE001 — network / auth errors: fall back to the web console link
log.warning("bot username unavailable error=%s", type(exc).__name__)
return None
return me.username or None
async def deep_link(code: str) -> str:
username = await bot_username()
return f"https://t.me/{username}?start={code}" if username else f"{CONSOLE_PATH}?code={code}"
def memory_outbox() -> MemoryOutbox | None:
"""The in-memory outbox polled by the web console, also when wrapped (Telegram keeps it as `fallback`)."""
box: Outbox | None = integrations.outbox
for _ in range(5):
if isinstance(box, MemoryOutbox):
return box
box = getattr(box, "fallback", None)
return None
async def reset_demo() -> bool:
"""Clock reset is done by the caller; re-seed via `scripts.seed_demo.reset()` (#18) when it exists."""
box = memory_outbox()
if box is not None:
box._q.clear()
try:
spec = util.find_spec(SEED_MODULE)
except ModuleNotFoundError: # no `scripts` package on the path (e.g. installed app without scripts/)
spec = None
if spec is None:
return False
reset = getattr(import_module(SEED_MODULE), "reset", None)
if reset is None:
return False
result = reset()
if inspect.isawaitable(result):
await result
return True
+368
View File
@@ -0,0 +1,368 @@
"""Doctor dashboard read models (#12): clinical DB rows → frozen API schemas (`app/api/schemas.py`).
Everything shown to the doctor is derived from stored structured data — no LLM decides colours, trends or the
discontinuation risk (ADR-007). Time comes in as `now` (from `app.core.clock`, ADR-005).
Identity data (guardrail #5): the API is the doctor's display boundary, so it reads exactly two identity fields —
`display_name` (minimal identification for the treating doctor) and `birth_year` (age). SNILS, phone and chat
id are never selected into a response model.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Literal, cast, get_args
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import (
AlertOut,
Color,
DiscontinuationRisk,
DoseOut,
LevelName,
Metric,
Module,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.core.rules import color, patient_status
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module))
_METRICS = frozenset(get_args(Metric))
_SCALE_LABELS = {"phq9": "PHQ-9", "pcl5": "PCL-5", "gad7": "GAD-7", "auditc": "AUDIT-C", "dar5": "DAR-5"}
# Daily check-in buckets → numbers for charts. Sleep: bucket midpoints in hours ("<4" plotted as 3, ">8" as 9).
SLEEP_HOURS = {"<4": 3.0, "4-6": 5.0, "6-8": 7.0, ">8": 9.0}
# Alcohol: 0 = none, 1 = 1–2 drinks, 2 = more (same coding as app/llm/summary.py expects).
ALCOHOL_LEVEL = {"none": 0.0, "1-2": 1.0, "more": 2.0}
# Trend: the last two complete results of one scale. A change smaller than about half of the minimal clinically
# important difference (PHQ-9: 5 points, PCL-5: 10 points) is reported as "flat" so noise does not flip arrows.
TREND_MIN_CHANGE = {"phq9": 3, "pcl5": 5}
CARD_DAYS = 60 # how far back the card series / dose log reach
# --------------------------------------------------------------------------- discontinuation risk index
#
# Rule-based index (NOT a trained model) of the risk that the patient drops the therapy, 0–100, for sorting the
# list and for the "talk about adherence" hint. Each factor is a deterministic check over the clinical DB with a
# fixed weight; the score is the capped sum. Weights reflect the known predictors of early antidepressant
# discontinuation (non-adherence, side effects in the first weeks, expressed intent to stop, low mood, alcohol):
#
# factor weight label for the doctor
# missed/skipped doses on >= 3 of the last 7 days 30 "пропуски приёма N/7 дн."
# ... on 1–2 of the last 7 days 15 "пропуски приёма N/7 дн."
# side effects reported >= 3 days in a row 20 "побочные эффекты N дн. подряд"
# ... 1–2 days in a row 10 "побочные эффекты N дн. подряд"
# intent to stop medication voiced in last 14 days 25 "хотел бросить (день N)"
# open, unacknowledged quit_intent alert +15 "не разобран сигнал «хочет бросить»"
# mood <= 3 for >= 3 days in a row 15 "настроение ≤ 3 N дн. подряд"
# alcohol on >= 3 of the last 7 days 15 "алкоголь N дн. из 7"
# ... on 1–2 of the last 7 days 5 "алкоголь N дн. из 7"
#
# Level: >= 60 high, >= 30 medium, otherwise low. "Day" = calendar day in the clinic time zone; streaks end today
# if today's check-in exists, otherwise yesterday (same anchor as app.core.rules), and a day without a check-in
# breaks a streak — we never assume a symptom the patient did not report.
RISK_WINDOW_DAYS = 7
QUIT_WINDOW_DAYS = 14
MOOD_LOW = 3
W_MISSED_MANY, W_MISSED_SOME = 30, 15
W_SIDE_EFFECTS_LONG, W_SIDE_EFFECTS_SHORT = 20, 10
W_QUIT_INTENT, W_QUIT_ALERT_OPEN = 25, 15
W_MOOD_LOW = 15
W_ALCOHOL_MANY, W_ALCOHOL_SOME = 15, 5
STREAK_DAYS = 3
RISK_HIGH, RISK_MEDIUM = 60, 30
@dataclass
class PatientData:
"""Everything the list item and the card are computed from (one patient, loaded once)."""
profile: PatientProfile
checkins: Sequence[DailyCheckin]
results: Sequence[ScaleResult]
doses: Sequence[Dose]
prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
token = profile.token
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all()
return PatientData(
profile=profile,
checkins=await repo.list_checkins(session, token),
results=await repo.list_scale_results(session, token),
doses=doses,
prescriptions=rx,
alerts=await repo.list_alerts(session, token=token),
)
def _local(ts: datetime, now: datetime) -> date:
return ts.astimezone(now.tzinfo).date()
def _streak_days(checkins: Iterable[DailyCheckin], today: date, pred: Any) -> int:
"""Length of the run of consecutive check-in days satisfying `pred`, ending at the anchor day."""
by_day = {c.day: c for c in checkins if c.day <= today}
day = today if today in by_day else today - timedelta(days=1)
n = 0
while (c := by_day.get(day)) is not None and pred(c):
n += 1
day -= timedelta(days=1)
return n
def _therapy_day(profile: PatientProfile, day: date) -> int:
return (day - profile.therapy_start).days if profile.therapy_start else 0
def _missed_days(d: PatientData, now: datetime, first: date) -> set[date]:
# Same sources as rule missed_doses_7d: dose records and the check-in answer "did not take".
days = {_local(x.planned_at, now) for x in d.doses if x.status in ("missed", "skipped") and x.planned_at <= now}
days |= {c.day for c in d.checkins if c.med_taken is False}
return {x for x in days if first <= x <= now.date()}
def discontinuation_risk(d: PatientData, now: datetime) -> DiscontinuationRisk:
"""Deterministic discontinuation-risk index; see the weight table above."""
today = now.date()
first = today - timedelta(days=RISK_WINDOW_DAYS - 1)
score = 0
factors: list[str] = []
missed = len(_missed_days(d, now, first))
if missed:
score += W_MISSED_MANY if missed >= STREAK_DAYS else W_MISSED_SOME
factors.append(f"пропуски приёма {missed}/{RISK_WINDOW_DAYS} дн.")
side = _streak_days(d.checkins, today, lambda c: bool(c.side_effects))
if side:
score += W_SIDE_EFFECTS_LONG if side >= STREAK_DAYS else W_SIDE_EFFECTS_SHORT
factors.append(f"побочные эффекты {side} дн. подряд")
quit_since = today - timedelta(days=QUIT_WINDOW_DAYS - 1)
quit_days = [c.day for c in d.checkins if c.quit_intent and quit_since <= c.day <= today]
if quit_days:
score += W_QUIT_INTENT
factors.append(f"хотел бросить (день {_therapy_day(d.profile, max(quit_days))})")
if any(a.code == "quit_intent" and a.acked_at is None for a in d.alerts):
score += W_QUIT_ALERT_OPEN
factors.append("не разобран сигнал «хочет бросить»")
low = _streak_days(d.checkins, today, lambda c: c.mood is not None and c.mood <= MOOD_LOW)
if low >= STREAK_DAYS:
score += W_MOOD_LOW
factors.append(f"настроение ≤ {MOOD_LOW} {low} дн. подряд")
drinking = sum(1 for c in d.checkins if first <= c.day <= today and c.alcohol_bucket not in (None, "none"))
if drinking:
score += W_ALCOHOL_MANY if drinking >= STREAK_DAYS else W_ALCOHOL_SOME
factors.append(f"алкоголь {drinking} дн. из {RISK_WINDOW_DAYS}")
score = min(score, 100)
level: Literal["low", "medium", "high"] = (
"high" if score >= RISK_HIGH else "medium" if score >= RISK_MEDIUM else "low"
)
return DiscontinuationRisk(score=score, level=level, factors=factors)
# --------------------------------------------------------------------------- list item
def _complete(d: PatientData, scale: str) -> list[ScaleResult]:
return [r for r in d.results if r.scale == scale and r.complete] # repo returns them oldest first
def trend(d: PatientData) -> Literal["up", "down", "flat", "unknown"]:
"""Direction of symptom scores (up = worse) from the scale with the most recent pair of results."""
pairs = [(s, rs[-2:]) for s in TREND_MIN_CHANGE if len(rs := _complete(d, s)) >= 2]
if not pairs:
return "unknown"
scale, (prev, last) = max(pairs, key=lambda p: p[1][-1].ts)
delta = last.total - prev.total
if abs(delta) < TREND_MIN_CHANGE[scale]:
return "flat"
return "up" if delta > 0 else "down"
def adherence_7d(d: PatientData, now: datetime) -> float | None:
"""Taken / planned doses of the last 7 days; doses still `pending` (not resolved yet) are not counted."""
since = now - timedelta(days=RISK_WINDOW_DAYS)
resolved = [x for x in d.doses if since < x.planned_at <= now and x.status != "pending"]
if not resolved:
return None
return round(sum(1 for x in resolved if x.status == "taken") / len(resolved), 2)
def _drug_label(rx: Prescription) -> str:
return f"{rx.drug} {rx.dose}".strip()
def _active_rx(d: PatientData, today: date) -> Prescription | None:
active = [
r
for r in d.prescriptions
if (r.start_date is None or r.start_date <= today) and (r.end_date is None or r.end_date >= today)
]
return (active or list(d.prescriptions) or [None])[-1]
def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None
if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
return alert.reason if alert is not None else ""
def _age(patient: Patient | None, profile: PatientProfile, today: date) -> int:
birth_year = (patient.birth_year if patient else None) or profile.birth_year
return today.year - birth_year if birth_year else 0
def list_item(d: PatientData, patient: Patient | None, now: datetime) -> PatientListItem:
p = d.profile
today = now.date()
open_alerts = [a for a in d.alerts if a.acked_at is None]
# ADR-006: the colour is derived from open alert levels (the source of truth), not from the cached column.
status = patient_status(AlertLevel[a.level.upper()] for a in open_alerts)
phq9, pcl5 = _complete(d, "phq9"), _complete(d, "pcl5")
rx = _active_rx(d, today)
return PatientListItem(
token=p.token,
display_name=patient.display_name if patient else "Пациент",
age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None,
therapy_day=_therapy_day(p, today),
next_visit=p.next_visit,
status_color=status.color,
top_alert_level=cast(LevelName, status.top_level.name.lower()) if status.top_level is not None else None,
open_alerts=len(open_alerts),
phq9_last=phq9[-1].total if phq9 else None,
pcl5_last=pcl5[-1].total if pcl5 else None,
trend=trend(d),
adherence_7d=adherence_7d(d, now),
discontinuation_risk=discontinuation_risk(d, now),
last_event=_last_event(d, now),
)
def sort_key(item: PatientListItem) -> tuple[int, int, str]:
"""red → yellow → grey → green, then discontinuation risk (desc), then name for a stable order."""
return (COLOR_ORDER[item.status_color], -item.discontinuation_risk.score, item.display_name)
# --------------------------------------------------------------------------- card
def alert_out(a: Alert, patient_name: str) -> AlertOut:
level = cast(LevelName, a.level)
return AlertOut(
id=a.id,
token=a.token,
patient_name=patient_name,
ts=a.ts,
level=level,
color=cast(Color, color([AlertLevel[a.level.upper()]])),
code=a.code,
reason=a.reason,
acked_by=a.acked_by,
acked_at=a.acked_at,
)
def series(d: PatientData, now: datetime) -> list[SeriesPoint]:
since = now.date() - timedelta(days=CARD_DAYS)
out: list[SeriesPoint] = []
for c in d.checkins:
if not since <= c.day <= now.date():
continue
if c.mood is not None:
out.append(SeriesPoint(day=c.day, metric="mood", value=float(c.mood)))
if c.sleep_bucket in SLEEP_HOURS:
out.append(SeriesPoint(day=c.day, metric="sleep_hours", value=SLEEP_HOURS[c.sleep_bucket]))
if c.alcohol_bucket in ALCOHOL_LEVEL:
out.append(SeriesPoint(day=c.day, metric="alcohol", value=ALCOHOL_LEVEL[c.alcohol_bucket]))
for r in d.results:
day = _local(r.ts, now)
if r.complete and r.scale in _METRICS and since <= day and r.ts <= now:
out.append(SeriesPoint(day=day, metric=cast(Metric, r.scale), value=float(r.total), scheduled=r.scheduled))
return sorted(out, key=lambda p: (p.day, p.metric))
def doses(d: PatientData, now: datetime) -> list[DoseOut]:
labels = {r.id: _drug_label(r) for r in d.prescriptions}
since = now - timedelta(days=CARD_DAYS)
return [
DoseOut(planned_at=x.planned_at, drug=labels.get(x.prescription_id, ""), status=x.status) # type: ignore[arg-type]
for x in d.doses
if since <= x.planned_at <= now + timedelta(days=1)
]
def prescriptions(d: PatientData) -> list[PrescriptionOut]:
out = []
for r in d.prescriptions:
schedule = dict(r.schedule or {})
out.append(
PrescriptionOut(
drug=r.drug,
dose=r.dose,
schedule={"times": [str(t) for t in schedule.get("times", [])]},
instructions=" ".join(str(t) for t in _as_list(schedule.get("instructions"))),
start_date=r.start_date,
end_date=r.end_date,
)
)
return out
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
return list(value) if isinstance(value, list) else [value]
def _plan_line(step: Any) -> str:
# Same rendering as the dialog's safety-plan reply (app/dialog/actions.py).
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan(session: AsyncSession, token: str) -> list[str] | None:
row = await session.get(SafetyPlan, token)
return [_plan_line(s) for s in row.steps] if row is not None and row.steps else None
async def build_card(session: AsyncSession, d: PatientData, patient: Patient | None, now: datetime) -> PatientCard:
"""Card without the summary (the caller adds it: the LLM call must not hold the DB session)."""
item = list_item(d, patient, now)
return PatientCard(
patient=item,
prescriptions=prescriptions(d),
series=series(d, now),
doses=doses(d, now),
alerts=[alert_out(a, item.display_name) for a in d.alerts],
safety_plan=await safety_plan(session, d.profile.token),
)
+12 -5
View File
@@ -27,6 +27,7 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select from sqlalchemy import select
from app.api import fixtures from app.api import fixtures
from app.api.schemas import DemoPersona
from app.core import clock from app.core import clock
from app.core.instrument_catalog import Catalog, catalog from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS from app.core.intake_questions import DIARY_KINDS
@@ -133,9 +134,15 @@ async def auth_emias(body: EmiasCodeIn) -> AuthOut:
return AuthOut(token=person.token, mode="emias") 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) @router.post("/auth/emias/demo", response_model=AuthOut)
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut: async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/demo/personas`).""" """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) persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None: if persona is None:
raise HTTPException(404, "persona not found") raise HTTPException(404, "persona not found")
@@ -148,13 +155,13 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut: async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; no identity record, no doctor. """Anonymous sign-in: a fresh random token; no identity record, no doctor.
With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
matching onboarding instead of going straight to the menu. Without it nothing is stored at all. 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) token = secrets.token_hex(TOKEN_LEN // 2)
if body is not None and body.focus is not None: focus = body.focus if body is not None else None
async with get_clinical_session() as s: async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]]) 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]) log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous") return AuthOut(token=token, mode="anonymous")
+190 -21
View File
@@ -1,11 +1,20 @@
"""API routes `/api/v1` (#34 stubs → #12 implementation). """API routes `/api/v1` (#34 contract → #12 implementation).
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately. Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract). Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`.
Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`,
`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are
disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`.
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
""" """
from fastapi import APIRouter, HTTPException import secrets
from fastapi import APIRouter, HTTPException
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 import fixtures
from app.api.patient_web import router as patient_web_router from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router from app.api.questionnaire import router as questionnaire_router
@@ -24,70 +33,204 @@ from app.api.schemas import (
PatientListItem, PatientListItem,
) )
from app.core import clock from app.core import clock
from app.dialog.contracts import get_engine from app.core.privacy import pseudo_id
from app.integrations.base import MemoryOutbox, integrations from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import Alert, Message, PatientProfile
from app.db.clinical import Prescription as PrescriptionRow
from app.db.identity import Patient
from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations
from app.scheduler import jobs as scheduler from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router) router.include_router(questionnaire_router)
router.include_router(patient_web_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
CODE_LEN = 8
async def _patients(tokens: list[str]) -> dict[str, Patient]:
"""Identity rows for display name / age only (never put SNILS or phone into a response)."""
if not tokens:
return {}
async with get_identity_session() as s:
rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all()
return {p.token: p for p in rows}
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else "Пациент"
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"]) @router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
async def list_patients(doctor_id: str) -> list[PatientListItem]: async def list_patients(doctor_id: str) -> list[PatientListItem]:
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk.""" """Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3} now = clock.now()
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score)) async with get_clinical_session() as s:
profiles = await repo.patients_of_doctor(s, doctor_id)
data = [await views.load_patient_data(s, p) for p in profiles]
await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now)
patients = await _patients([p.token for p in profiles])
items = [views.list_item(d, patients.get(d.profile.token), now) for d in data]
return sorted(items, key=views.sort_key)
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"]) @router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
async def patient_card(token: str, doctor_id: str) -> PatientCard: async def patient_card(token: str, doctor_id: str) -> PatientCard:
"""Patient card. Every call is written to audit_log as `view_patient` (#12).""" """Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
card = fixtures.card(token) now = clock.now()
if card is None: patient = (await _patients([token])).get(token)
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found") raise HTTPException(404, "patient not found")
if profile.doctor_id != doctor_id:
await audit(s, actor=doctor_id, action="view_patient_denied", token=token, ts=now)
await s.commit()
raise HTTPException(403, "not your patient")
data = await views.load_patient_data(s, profile)
card = await views.build_card(s, data, patient, now)
await audit(s, actor=doctor_id, action="view_patient", token=token, ts=now)
# Summary after the commit: the (possibly slow) LLM call must not hold the DB transaction.
from app.llm.summary import build_summary # summary imports app.api.schemas; avoid an import cycle
card.summary = await build_summary(card, today=now.date())
return card return card
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"]) @router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]: async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first.""" """Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
alerts = [a for a in fixtures.all_alerts() if a.id > since_id] async with get_clinical_session() as s:
return [a for a in alerts if a.acked_at is None] if open_only else alerts tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id)))
stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id)
if open_only:
stmt = stmt.where(Alert.acked_at.is_(None))
alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
if alerts:
await audit(
s,
actor=doctor_id,
action="view_alerts",
token=None,
details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)},
ts=clock.now(),
)
patients = await _patients(sorted({a.token for a in alerts}))
return [views.alert_out(a, _name(patients, a.token)) for a in alerts]
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"]) @router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut: async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis.""" """Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None) now = clock.now()
async with get_clinical_session() as s:
alert = await s.get(Alert, alert_id)
if alert is None: if alert is None:
raise HTTPException(404, "alert not found") raise HTTPException(404, "alert not found")
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()}) profile = await repo.get_profile(s, alert.token)
if profile is None or profile.doctor_id != body.doctor_id:
raise HTTPException(403, "not your patient")
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
await refresh_status(s, alert.token)
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
out = views.alert_out(alert, "")
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
if alert.level == "acute" and not open_acute:
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
if replies:
await integrations.outbox.push(alert.token, replies)
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"]) @router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut: async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR.""" """Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001") now = clock.now()
try:
token = pseudo_id(body.snils)
except ValueError as exc:
raise HTTPException(422, "SNILS must contain 11 digits") from exc
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
async with get_identity_session() as ids:
if await identity_repo.get_patient(ids, token) is not None:
raise HTTPException(409, "patient already connected")
doctor = await identity_repo.get_doctor(ids, body.doctor_id)
await identity_repo.create_patient(
ids,
token=token,
display_name=body.display_name,
snils=body.snils,
clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC,
doctor_id=body.doctor_id,
now=now,
birth_year=body.birth_year,
sex=body.sex,
)
await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now)
# Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too.
async with get_clinical_session() as s:
await repo.upsert_profile(
s,
token=token,
doctor_id=body.doctor_id,
modules=list(body.modules),
therapy_start=now.date(),
next_visit=body.next_visit,
birth_year=body.birth_year,
sex=body.sex,
)
schedule: dict[str, list[str]] = {"times": list(body.times)}
if body.instructions:
schedule["instructions"] = [body.instructions]
s.add(
PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date())
)
await audit(
s,
actor=body.doctor_id,
action="connect_patient",
token=token,
details={"modules": list(body.modules), "drug": body.drug},
ts=now,
)
return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code))
# ------------------------------------------------------------------ patient dialog (web console / any channel) # ------------------------------------------------------------------ patient dialog (web console / any channel)
async def _require_patient(token: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"]) @router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut: async def dialog_event(token: str, body: DialogIn) -> DialogOut:
await _require_patient(token)
return DialogOut(replies=await get_engine().handle(token, body)) return DialogOut(replies=await get_engine().handle(token, body))
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"]) @router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut: async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console.""" """System-initiated replies (reminders, check-ins) waiting for the web console."""
box = integrations.outbox box = console.memory_outbox()
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else []) return DialogOut(replies=box.drain(token) if box is not None else [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"]) @router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]: async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return [] """Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages."""
async with get_clinical_session() as s:
stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc())
rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all()
return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type]
# ------------------------------------------------------------------ demo controls (ADR-005) # ------------------------------------------------------------------ demo controls (ADR-005)
@@ -110,10 +253,36 @@ async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
@router.post("/demo/reset", tags=["demo"]) @router.post("/demo/reset", tags=["demo"])
async def demo_reset() -> dict[str, str]: async def demo_reset() -> dict[str, str]:
"""Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18)."""
clock.reset() clock.reset()
return {"status": "ok"} seeded = await console.reset_demo()
return {"status": "ok", "seed": "reset" if seeded else "skipped"}
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]: async def demo_personas() -> list[DemoPersona]:
return fixtures.DEMO_PERSONAS """Patients with a scripted scenario `data/synthetic/<slug>.jsonl`; the demo cabinet's doctor first."""
scenarios = console.scenario_files()
if not scenarios:
return []
async with get_identity_session() as ids:
patients = (await ids.scalars(select(Patient))).all()
scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios]
if not scripted:
return []
today = clock.today()
async with get_clinical_session() as s:
profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted}
out = []
for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)):
profile = profiles.get(p.token)
start = profile.therapy_start if profile else None
out.append(
DemoPersona(
token=p.token,
display_name=p.display_name,
therapy_day=(today - start).days if start else 0,
scenario=path,
)
)
return out
+10
View File
@@ -28,6 +28,16 @@ class Settings(BaseSettings):
gigachat_model: str = "GigaChat" gigachat_model: str = "GigaChat"
gigachat_verify_ssl: bool = False gigachat_verify_ssl: bool = False
# Chat agent (tool-using LLM over RAG + patient record). `auto` = OpenRouter if a key is set, else off
# (extractive RAG / instruction answers stay). OpenRouter is a TEMPORARY prototype provider on synthetic
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi. # Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = "" tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None doctor_notify_chat_id: int | None = None
+1
View File
@@ -39,6 +39,7 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
buttons = [ buttons = [
[Button(label=b.checkin, data="menu:checkin")], [Button(label=b.checkin, data="menu:checkin")],
[Button(label=b.med_question, data="menu:med_question")], [Button(label=b.med_question, data="menu:med_question")],
[Button(label=b.kb_question, data="menu:kb_question")],
[Button(label=b.contact_doctor, data="menu:contact_doctor")], [Button(label=b.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")], [Button(label=b.revoke, data="consent:revoke")],
] ]
+107
View File
@@ -0,0 +1,107 @@
"""Questions about the condition and its treatment → knowledge base of clinical guidelines (#64, RAG #60).
menu «Вопрос о лечении» ──▶ kb.question (free text) ──▶ rag.answer: KR quote + source, or «уточните у врача»
free text in idle that looks like a question (veteran_daily `text:idle`, after the check-in signals and the
drug question) ──▶ rag.answer, or the usual support phrase when the KB has no answer
The engine has already scrubbed the text, checked consent and run the safety check (ACUTE never gets here).
Questions about the patient's own drug stay with `med_qa` (instruction only, SPEC §7 п.5); this flow answers
general questions from the guidelines, extractively and without doses (`app.llm.rag.qa`). Logs carry codes only.
"""
import logging
import re
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict
from app.config import get_settings
from app.dialog.contracts import Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger
from app.llm import agent, rag
from app.llm.med_qa import DOSE_QUESTION
log = logging.getLogger(__name__)
QUESTION_STATE = "kb.question"
# `scrub()` placeholders and the punctuation around them: "[NAME], что такое ПТСР?" starts with "что такое".
_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+")
class KnowledgeTexts(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
prompt: str
dose_redirect: str
question_starts: list[str]
small_talk: list[str]
@lru_cache
def load_knowledge_texts(path: Path | None = None) -> KnowledgeTexts:
path = path or get_settings().config_dir / "texts" / "knowledge.yaml"
return KnowledgeTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@lru_cache
def _question_start() -> re.Pattern[str]:
starts = sorted(load_knowledge_texts().question_starts, key=len, reverse=True)
return re.compile(r"^\s*(?:" + "|".join(re.escape(s) for s in starts) + r")\b", re.IGNORECASE)
@lru_cache
def _small_talk() -> re.Pattern[str]:
return re.compile(r"\b(?:" + "|".join(re.escape(s) for s in load_knowledge_texts().small_talk) + r")\b", re.I)
def looks_like_question(text: str) -> bool:
"""A "?" anywhere or a question word at the start ("что такое флэшбек", "можно ли ..."); small talk
("как дела?") is not a question for the knowledge base."""
text = _PLACEHOLDERS.sub("", text)
if _small_talk().search(text):
return False
return "?" in text or bool(_question_start().search(text))
async def knowledge_answer(ctx: Ctx, *, explicit: bool) -> str | None:
"""KB answer for `ctx.text`. No answer → «уточните у врача» if the patient asked via the menu (`explicit`),
`None` otherwise (the caller keeps its own reply, e.g. the support phrase)."""
if ctx.text is None:
return None
if DOSE_QUESTION.search(ctx.text.lower()):
log.info("kb: dose question redirected to doctor")
return load_knowledge_texts().dose_redirect
# Tool-using agent first (RAG + ЕМИАС record + drug-safety check); disabled/failed → extractive RAG below.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
log.info("kb: agent answered provider=%s tools=%s", smart.provider, ",".join(smart.tools))
return agent.render(smart)
reply = await rag.answer(ctx.text, audience="patient")
if reply is not None:
log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations))
return reply.text
log.info("kb: no answer explicit=%s", explicit)
return rag.fallback_text("patient") if explicit else None
@trigger("button:menu:kb_question")
async def _menu_question(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=load_knowledge_texts().prompt)], next_state=QUESTION_STATE)
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
# A menu button or a reminder instead of the question: drop the question and let idle handle it.
step = Step(next_state="idle")
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
text = await knowledge_answer(ctx, explicit=True)
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# A question asked yesterday must not swallow today's message.
abandonable("kb")
+7 -1
View File
@@ -42,7 +42,8 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa from app.llm import agent
from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question" QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text" SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
@@ -441,6 +442,11 @@ async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача».""" """Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None: if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription return load_meds_texts().question.no_prescription
if DOSE_QUESTION.search(ctx.text.lower()) is None:
# Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return agent.render(smart)
answer = await med_qa(rx.drug, ctx.text) answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text() return answer.text if answer is not None else fallback_text()
+7 -2
View File
@@ -47,6 +47,7 @@ from app.db import repo
from app.dialog.contracts import Button, Event, Reply from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows import intake from app.dialog.flows import intake
from app.dialog.flows.core import menu_reply from app.dialog.flows.core import menu_reply
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
from app.dialog.flows.meds import ( from app.dialog.flows.meds import (
answer_question, answer_question,
checkin_of, checkin_of,
@@ -423,11 +424,13 @@ async def _visit(ctx: Ctx, event: Event) -> Step:
@trigger("text:idle", replace=True) @trigger("text:idle", replace=True)
async def _idle_text(ctx: Ctx, event: Event) -> Step: async def _idle_text(ctx: Ctx, event: Event) -> Step:
"""Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol """Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol
→ today's check-in (the rules react); otherwise the neutral support phrase with the menu.""" → today's check-in (the rules react); another question → clinical guidelines (knowledge base); otherwise
the neutral support phrase with the menu."""
assert ctx.text is not None assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text) facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None: if facts is None:
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))]) text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None
return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))])
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
if facts.quit_intent: if facts.quit_intent:
fields["quit_intent"] = True fields["quit_intent"] = True
@@ -445,6 +448,8 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
await save_checkin(ctx, **fields) await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent: if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx)) text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None: if text is None:
text = await ctx.gateway.phrase("support") text = await ctx.gateway.phrase("support")
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects)) return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
+1
View File
@@ -47,6 +47,7 @@ class AgeGateTexts(_Strict):
class IdleButtons(_Strict): class IdleButtons(_Strict):
checkin: str checkin: str
med_question: str med_question: str
kb_question: str
contact_doctor: str contact_doctor: str
revoke: str revoke: str
+51
View File
@@ -0,0 +1,51 @@
"""Demo adapters behind the frozen integration Protocols (SPEC §9, #14).
`setup()` runs when the package is first imported. Every consumer imports `app.integrations.base`, which
initialises this package first, and `app/main.py` is a frozen contract — so the registry is wired before
any request or job without a startup hook. Setup only constructs objects: data files and DB sessions are
opened lazily on the first call, so importing the package never fails on I/O.
Registry after setup:
- `prescriptions` — `MisMock`: clinical DB `prescriptions`, then `data/meds/mis_prescriptions.json`;
- `identity` — `ConnectCodeIdentity`: one-time connect code from the doctor (ЕСИА stand-in);
- `drug_info` — `GrlsExcerpts`: `data/meds/grls/*.yaml`;
- `notifier` — `CompositeNotifier`: in-memory dashboard feed + optional Telegram doctor chat;
- `outbox` — unchanged `MemoryOutbox` (the web console drains it; the Telegram channel wraps it at start).
"""
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.connect_code import ConnectCodeIdentity
from app.integrations.grls import GrlsExcerpts
from app.integrations.mis_mock import MisMock
from app.integrations.notify import CompositeNotifier, NotificationFeed
__all__ = ["CompositeNotifier", "ConnectCodeIdentity", "GrlsExcerpts", "MisMock", "NotificationFeed", "setup"]
_done = False
def setup(*, force: bool = False) -> None:
"""Register the demo adapters. Idempotent; never replaces an adapter someone already swapped in
(a test fake, the Telegram outbox), only the in-memory defaults from `base.py` — unless `force`."""
global _done
if _done and not force:
return
if force or isinstance(integrations.prescriptions, EmptyPrescriptions):
integrations.prescriptions = MisMock()
if force or isinstance(integrations.identity, RejectIdentity):
integrations.identity = ConnectCodeIdentity()
if force or isinstance(integrations.drug_info, NoDrugInfo):
integrations.drug_info = GrlsExcerpts()
# Exact type check: MemoryNotifier subclasses used as test sinks must stay in place.
if force or type(integrations.notifier) is MemoryNotifier:
integrations.notifier = CompositeNotifier()
_done = True
setup()
+58
View File
@@ -0,0 +1,58 @@
"""ЕСИА stand-in: the patient proves identity with the one-time connect code issued by the doctor.
The doctor creates the code in «Подключить пациента» (identity DB `connect_codes`); the patient opens the
QR deep link / enters the code. In prod this is ЕСИА OAuth; the `IdentityProvider` Protocol stays the same.
"""
import logging
from datetime import UTC, timedelta
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.integrations.base import VerifiedPerson
log = logging.getLogger(__name__)
# One connect flow verifies the same code twice: the Telegram channel on `/start <code>` (to bind the chat)
# and then the dialog engine's `new` state (to check the code belongs to this token). A short grace window
# lets the second check pass; after it a used code is rejected like an unknown one.
REDEEM_GRACE = timedelta(minutes=15)
class ConnectCodeIdentity:
"""`IdentityProvider` over identity-DB connect codes."""
def __init__(self, grace: timedelta = REDEEM_GRACE) -> None:
self.grace = grace
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip()
if not code:
return None
# Imported lazily: the integrations package is imported by clinical-side modules, which must not
# pull the identity schema into their import graph (guardrail #5).
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
now = clock.now()
try:
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
log.info("connect code rejected: unknown")
return None
person = VerifiedPerson(token=row.token, doctor_id=row.doctor_id)
if row.used_at is not None:
used_at = row.used_at if row.used_at.tzinfo else row.used_at.replace(tzinfo=UTC)
if now - used_at <= self.grace:
return person
log.info("connect code rejected: already used")
return None
if await identity_repo.redeem_connect_code(s, code=code, now=now) is None:
return None
return person
except (OSError, SQLAlchemyError) as exc:
log.warning("connect code check failed error=%s", type(exc).__name__)
return None
+94
View File
@@ -0,0 +1,94 @@
"""ЕМИАС medical record mock: diagnoses, comorbidities, allergies and other specialists' prescriptions.
Prod: the regional МИС / ЕГИСЗ adapter replaces `EmiasRecordSource` (same `get(token)` contract). Demo: synthetic
records from `data/emias/records.yaml` keyed by synthetic SNILS, converted to tokens with `pseudo_id()` at load
time — so the record handed to the chat agent carries no SNILS, name or phone (guardrail #1). The current
psychiatric prescription comes from the МИС mock (`integrations.prescriptions`).
"""
import logging
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.core.privacy import pseudo_id
from app.integrations.base import integrations
log = logging.getLogger(__name__)
class Diagnosis(BaseModel):
code: str
name: str
class Condition(BaseModel):
name: str
tags: list[str] = Field(default_factory=list)
class OtherMedication(BaseModel):
drug: str
how: str = ""
prescribed_by: str = ""
classes: list[str] = Field(default_factory=list)
class CurrentPrescription(BaseModel):
drug: str
dose: str
instructions: str = ""
class PatientRecord(BaseModel):
"""Clinical-only view of the record (no identifiers): what the chat agent may see."""
diagnoses: list[Diagnosis] = Field(default_factory=list)
conditions: list[Condition] = Field(default_factory=list)
allergies: list[Condition] = Field(default_factory=list)
other_medications: list[OtherMedication] = Field(default_factory=list)
pregnancy: dict[str, str] | None = None
current_prescriptions: list[CurrentPrescription] = Field(default_factory=list)
def default_records_path() -> Path:
return get_settings().data_dir / "emias" / "records.yaml"
@lru_cache
def _load(path: Path) -> dict[str, PatientRecord]:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, PatientRecord] = {}
for snils, rec in (raw.get("records") or {}).items():
out[pseudo_id(snils)] = PatientRecord.model_validate(rec)
return out
class EmiasRecordSource:
def __init__(self, path: Path | None = None) -> None:
self._path = path
async def get(self, token: str) -> PatientRecord | None:
try:
base = _load(self._path or default_records_path()).get(token)
except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__)
base = None
record = base.model_copy(deep=True) if base is not None else PatientRecord()
try:
rx = await integrations.prescriptions.get_prescriptions(token)
except Exception as exc: # noqa: BLE001 — the МИС being down must not break the chat
log.error("prescriptions unavailable: %s", type(exc).__name__)
rx = []
record.current_prescriptions = [
CurrentPrescription(drug=p.drug, dose=p.dose, instructions=p.instructions) for p in rx
]
if base is None and not rx:
return None
return record
emias_records = EmiasRecordSource()
+80
View File
@@ -0,0 +1,80 @@
"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5).
Each `data/meds/grls/<name>.yaml` holds one drug:
inn: сертралин
aliases: [sertraline]
sections:
side_effects: {text: "...", source: "ГРЛС, инструкция по медицинскому применению, раздел «...»"}
Section codes are the ones `app/llm/med_qa.py` understands (side_effects, onset, alcohol, discontinuation,
administration, interactions, overdose). The bot answers drug questions ONLY from these texts, so they are
the single place to verify against the current ГРЛС instruction (see the header of each file).
"""
import logging
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, ValidationError
from app.config import get_settings
from app.integrations.base import DrugSection
log = logging.getLogger(__name__)
class _SectionFile(BaseModel):
text: str
source: str
class _DrugFile(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
sections: dict[str, _SectionFile]
def default_grls_dir() -> Path:
return get_settings().data_dir / "meds" / "grls"
def normalize_inn(name: str) -> str:
"""Lookup key: «Сертралин 50 мг», «сертралин», «Sertraline» -> first word, lower case, ё -> е."""
words = name.strip().lower().replace("ё", "е").split()
return words[0] if words else ""
class GrlsExcerpts:
"""`DrugInfo` over local YAML excerpts. Files are read lazily on the first lookup and cached."""
def __init__(self, directory: Path | None = None) -> None:
self._dir = directory
self._index: dict[str, list[DrugSection]] | None = None
def _load(self) -> dict[str, list[DrugSection]]:
if self._index is not None:
return self._index
directory = self._dir or default_grls_dir()
index: dict[str, list[DrugSection]] = {}
for path in sorted(directory.glob("*.yaml")):
try:
drug = _DrugFile.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
except (OSError, yaml.YAMLError, ValidationError) as exc:
log.warning("grls file skipped file=%s error=%s", path.name, type(exc).__name__)
continue
sections = [
DrugSection(inn=drug.inn, section=code, text=" ".join(s.text.split()), source=s.source)
for code, s in drug.sections.items()
]
for key in (drug.inn, *drug.aliases):
index[normalize_inn(key)] = sections
self._index = index
return index
def known(self) -> list[str]:
"""INNs with excerpts (for the dashboard / tests)."""
return sorted({s[0].inn for s in self._load().values() if s})
async def sections(self, inn: str) -> list[DrugSection]:
return [s.model_copy() for s in self._load().get(normalize_inn(inn), [])]
+97
View File
@@ -0,0 +1,97 @@
"""МИС / ЕГИСЗ stand-in: the patient's prescriptions (SPEC §9).
Source order: the clinical DB table `prescriptions` (written when the doctor connects a patient or by the
demo seed), then the synthetic catalogue `data/meds/mis_prescriptions.json` keyed by token. In prod this is
a regional МИС query; the Protocol stays the same.
"""
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.integrations.base import Prescription
log = logging.getLogger(__name__)
class _CatalogPatient(BaseModel):
persona: str
tokens: list[str] = Field(default_factory=list)
prescriptions: list[Prescription]
class _Catalog(BaseModel):
patients: list[_CatalogPatient]
def _from_row(drug: str, dose: str, schedule: dict[str, Any], start: date | None, end: date | None) -> Prescription:
# The table has no instructions column; the doctor's note («утром после еды») may ride in the schedule JSON.
times = {k: [str(x) for x in v] for k, v in schedule.items() if isinstance(v, list)}
return Prescription(
drug=drug,
dose=dose,
schedule=times or {"times": ["08:00"]},
instructions=str(schedule.get("instructions") or ""),
start_date=start,
end_date=end,
)
def default_catalog_path() -> Path:
return get_settings().data_dir / "meds" / "mis_prescriptions.json"
class MisMock:
"""`PrescriptionSource`: clinical DB first, synthetic JSON catalogue as the fallback."""
def __init__(self, catalog_path: Path | None = None, *, use_db: bool = True) -> None:
self._path = catalog_path
self._use_db = use_db
self._by_token: dict[str, list[Prescription]] | None = None
def _catalog(self) -> dict[str, list[Prescription]]:
# Lazy: the package is imported before startup, a missing/broken file must not break the import.
if self._by_token is None:
path = self._path or default_catalog_path()
index: dict[str, list[Prescription]] = {}
try:
catalog = _Catalog.model_validate(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, ValidationError) as exc:
log.warning("mis_mock catalogue not loaded error=%s", type(exc).__name__)
catalog = _Catalog(patients=[])
for patient in catalog.patients:
for token in patient.tokens:
index[token] = patient.prescriptions
self._by_token = index
return self._by_token
async def _from_db(self, token: str) -> list[Prescription]:
from app.db.clinical import Prescription as PrescriptionRow
from app.db.session import get_clinical_session
async with get_clinical_session() as s:
rows = (
await s.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == token).order_by(PrescriptionRow.id)
)
).all()
return [_from_row(r.drug, r.dose, r.schedule or {}, r.start_date, r.end_date) for r in rows]
async def get_prescriptions(self, token: str) -> list[Prescription]:
if self._use_db:
try:
found = await self._from_db(token)
except (OSError, SQLAlchemyError) as exc:
# DB down or not migrated: the demo still shows the catalogue prescription.
log.warning("mis_mock db lookup failed error=%s", type(exc).__name__)
found = []
if found:
return found
return [p.model_copy(deep=True) for p in self._catalog().get(token, [])]
+141
View File
@@ -0,0 +1,141 @@
"""Doctor notifications (SPEC §9): in-memory feed for the dashboard + optional Telegram doctor chat.
The alert itself is already committed to the clinical DB before `send()` is called (the dashboard reads
alerts from there via the API), so every channel here is best effort: failures are logged, never raised.
Messages carry only the level, the rule reason/code and a short pseudonymous patient id — no PII
(guardrail #8); the reason texts come from `config/rules.yaml`, not from patient messages.
"""
import asyncio
import itertools
import logging
from collections import deque
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from app.config import get_settings
from app.core import clock
from app.integrations.base import AlertLevelName
log = logging.getLogger(__name__)
FEED_SIZE = 500
SHORT_ID_LEN = 6
MAX_REASON_CHARS = 200
TELEGRAM_TIMEOUT_S = 5.0
# Doctor-facing labels (not patient texts); acute alerts are visually distinct in the chat.
LEVEL_LABELS: dict[str, str] = {
"acute": "КРИТИЧНО",
"high": "Высокий",
"moderate": "Умеренный",
"info": "Инфо",
}
ChatSender = Callable[[int, str], Awaitable[Any]]
def short_id(token: str | None) -> str:
return token[:SHORT_ID_LEN] if token else "—"
def format_message(level: str, text: str, token: str | None) -> str:
reason = " ".join(text.split())
if len(reason) > MAX_REASON_CHARS:
reason = reason[: MAX_REASON_CHARS - 1] + "…"
return f"[{LEVEL_LABELS.get(level, level)}] {reason}\nПациент #{short_id(token)}"
class Notification(BaseModel):
id: int
ts: datetime
doctor_id: str
level: str
text: str
token: str | None = None
class NotificationFeed:
"""Bounded in-memory feed; ids increase monotonically so a poller asks only for `recent(since_id)`."""
def __init__(self, size: int = FEED_SIZE) -> None:
self._items: deque[Notification] = deque(maxlen=size)
self._ids = itertools.count(1)
def add(self, doctor_id: str, level: str, text: str, token: str | None, ts: datetime) -> Notification:
item = Notification(id=next(self._ids), ts=ts, doctor_id=doctor_id, level=level, text=text, token=token)
self._items.append(item)
return item
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return [n for n in self._items if n.id > since_id and (doctor_id is None or n.doctor_id == doctor_id)]
class TelegramDoctorChat:
"""Sends to the doctor chat `DOCTOR_NOTIFY_CHAT_ID` through the bot `TG_BOT_TOKEN`.
The aiogram `Bot` is created on the first message only; with either setting empty the channel is off.
"""
def __init__(self, chat_id: int | None = None, bot_token: str | None = None, sender: ChatSender | None = None):
settings = get_settings()
self.chat_id = chat_id if chat_id is not None else settings.doctor_notify_chat_id
self._bot_token = bot_token if bot_token is not None else settings.tg_bot_token
self._sender = sender
self._bot: Any = None
@property
def enabled(self) -> bool:
return self.chat_id is not None and (self._sender is not None or bool(self._bot_token))
def _get_sender(self) -> ChatSender:
if self._sender is None:
from aiogram import Bot
self._bot = Bot(self._bot_token)
self._sender = self._bot.send_message
return self._sender
async def send(self, level: str, text: str, token: str | None) -> None:
if not self.enabled or self.chat_id is None:
return
try:
await asyncio.wait_for(
self._get_sender()(self.chat_id, format_message(level, text, token)), timeout=TELEGRAM_TIMEOUT_S
)
except Exception as exc: # noqa: BLE001 — any channel error; the alert is already in the DB
err = type(exc).__name__
log.warning("doctor chat notify failed level=%s tok=%s error=%s", level, short_id(token), err)
async def aclose(self) -> None:
if self._bot is not None:
await self._bot.session.close()
self._bot = None
self._sender = None
class CompositeNotifier:
"""`Notifier`: dashboard feed + Telegram doctor chat (when configured)."""
def __init__(self, feed: NotificationFeed | None = None, chat: TelegramDoctorChat | None = None) -> None:
self.feed = feed or NotificationFeed()
self._chat = chat
@property
def chat(self) -> TelegramDoctorChat:
# Lazy: settings are read on the first notification, not when the package is imported.
if self._chat is None:
self._chat = TelegramDoctorChat()
return self._chat
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return self.feed.recent(since_id, doctor_id)
async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None:
try:
self.feed.add(doctor_id, level, text, token, clock.now())
except Exception as exc: # noqa: BLE001 — never break the caller
log.warning("notify feed failed error=%s", type(exc).__name__)
await self.chat.send(level, text, token)
+151
View File
@@ -0,0 +1,151 @@
"""Chat agent: tool-using LLM over the knowledge base (RAG), the ЕМИАС record and the drug-safety reference.
from app.llm.agent import answer_chat
reply = await answer_chat(token, scrubbed_question) # None → caller falls back to extractive RAG / med_qa
Guarantees (on top of what the dialog engine already did — scrub, consent, safety check; ACUTE never gets here):
* only scrubbed text goes to the provider (`assert_scrubbed`), tool results carry no identifiers;
* tools are bound to the current token server-side; read-only;
* the final text passes the gateway's forbidden-output filter (diagnoses, dose/drug advice); a violation or any
provider error → None, so the patient gets the safe extractive answer instead;
* logs carry tool names and counts only.
"""
import asyncio
import json
import logging
from typing import Any
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
MockAgentProvider,
OpenRouterProvider,
ProviderError,
ToolChatProvider,
)
from app.llm.agent.tools import TOOL_SPECS, ToolContext, run_tool
from app.llm.gateway import is_forbidden
from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
class AgentAnswer(BaseModel):
text: str
sources: list[str]
tools: list[str]
provider: str
def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings()
mode = s.chat_agent
if mode == "auto":
mode = "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter":
if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
return None
return OpenRouterProvider(s)
if mode == "mock":
return MockAgentProvider()
if mode == "gigachat":
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
log.warning("chat agent: gigachat provider not implemented yet — disabled")
return None
return None
async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record()
if rec is None or not rec.current_prescriptions:
return "\n\nТекущее назначение психиатра: нет данных."
drugs = ", ".join(p.drug for p in rec.current_prescriptions)
return f"\n\nТекущее назначение психиатра: {drugs}. «Мои таблетки» = это назначение."
def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending."""
return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
async def answer_chat(
token: str, question: ScrubbedText, *, provider: ToolChatProvider | None = None, settings: Settings | None = None
) -> AgentAnswer | None:
s = settings or get_settings()
provider = provider or build_provider(s)
if provider is None:
return None
try:
assert_scrubbed(question)
except ValueError:
log.warning("chat agent: unscrubbed input rejected")
return None
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
{"role": "user", "content": str(question)},
]
nudged = False
try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS)
if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results.
nudged = True
messages.append({"role": "user", "content": NUDGE})
continue
return _finish(turn.content or "", ctx, provider.name)
messages.append(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
],
}
)
for c in turn.tool_calls:
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout")
return None
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
text = text.strip()
if not text:
return None
if is_forbidden(text):
log.warning("chat agent: forbidden output filtered provider=%s", provider)
return None
if len(text) > MAX_ANSWER_CHARS:
text = text[:MAX_ANSWER_CHARS].rsplit(" ", 1)[0] + "…"
log.info("chat agent ok provider=%s tools=%s sources=%d", provider, ",".join(ctx.used), len(ctx.sources))
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
+59
View File
@@ -0,0 +1,59 @@
"""Try the chat agent from the shell (synthetic personas only):
uv run python -m app.llm.agent "Можно мне ибупрофен вместе с моими таблетками?" --persona andrey
CHAT_AGENT=mock uv run python -m app.llm.agent "Есть ли лактоза в сертралине?"
Prescriptions come from the МИС mock (DB if reachable, else data/meds/mis_prescriptions.json); the question is
scrubbed before it reaches any provider.
"""
import argparse
import asyncio
import logging
from app.config import get_settings
from app.core.privacy import pseudo_id, scrub
from app.integrations.base import Prescription, integrations
from app.llm.agent import answer_chat, build_provider, render
PERSONAS = { # synthetic SNILS from scripts/seed_demo.py → current psychiatric prescription
"andrey": ("900-000-013 34", "сертралин"),
"dmitry": ("900-000-041 01", "сертралин"),
"sergey": ("900-000-037 02", "пароксетин"),
"nikolay": ("900-000-052 03", "сертралин"),
"anna": ("900-000-023 04", "флуоксетин"),
"pavel": ("900-000-045 07", "эсциталопрам"),
}
class _FixedRx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="по назначению врача")]
async def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("question")
p.add_argument("--persona", default="andrey", choices=sorted(PERSONAS))
p.add_argument("-v", "--verbose", action="store_true")
a = p.parse_args()
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING)
snils, drug = PERSONAS[a.persona]
integrations.prescriptions = _FixedRx(drug) # CLI runs without the DB
provider = build_provider()
if provider is None:
raise SystemExit(
f"agent disabled (CHAT_AGENT={get_settings().chat_agent}); set OPENROUTER_API_KEY or CHAT_AGENT=mock"
)
ans = await answer_chat(pseudo_id(snils), scrub(a.question), provider=provider)
if ans is None:
raise SystemExit("no answer (provider error, filtered output or nothing found) — chat falls back to RAG")
print(render(ans))
print(f"\n[provider={ans.provider} tools={', '.join(ans.tools)}]")
if __name__ == "__main__":
asyncio.run(main())
+147
View File
@@ -0,0 +1,147 @@
"""Deterministic drug-safety lookup: composition, contraindications, interactions, patient-specific findings.
The LLM never decides whether a drug is safe for the patient (ADR-007 spirit): it calls `check_patient` and
retells the findings. Matching is by tags shared with the ЕМИАС record (`data/emias/records.yaml`):
condition tags → `cautions[].when_condition`, allergy tags → `composition.excipient_tags`, other medication
classes → `interactions[].with_class`. Source data: `data/meds/drug_safety.yaml` (prototype retelling of ГРЛС).
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.integrations.emias_record import PatientRecord
Severity = Literal["high", "medium", "low"]
class Caution(BaseModel):
when_condition: str
severity: Severity
text: str
class Interaction(BaseModel):
with_class: str
severity: Severity
text: str
class Composition(BaseModel):
form: str
active: str
excipients: list[str] = Field(default_factory=list)
excipient_tags: list[str] = Field(default_factory=list)
note: str = ""
class DrugEntry(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
drug_class: str = ""
classes: list[str] = Field(default_factory=list) # classes this drug belongs to (e.g. nsaid, ssri)
contraindications: list[str] = Field(default_factory=list)
cautions: list[Caution] = Field(default_factory=list)
interactions: list[Interaction] = Field(default_factory=list)
composition: Composition | None = None
pregnancy: str = ""
source: str = ""
class Finding(BaseModel):
severity: Severity
kind: Literal["condition", "allergy", "interaction", "pregnancy", "contraindication"]
text: str
class SafetyDb(BaseModel):
drugs: dict[str, DrugEntry]
allergy_labels: dict[str, str] = Field(default_factory=dict)
def _path() -> Path:
return get_settings().data_dir / "meds" / "drug_safety.yaml"
@lru_cache
def load_db(path: Path | None = None) -> SafetyDb:
raw = yaml.safe_load((path or _path()).read_text(encoding="utf-8"))
classes_of: dict[str, list[str]] = {}
for cls, members in (raw.get("drug_classes") or {}).items():
for m in members:
classes_of.setdefault(m, []).append(cls)
drugs: dict[str, DrugEntry] = {}
for inn, d in (raw.get("drugs") or {}).items():
entry = DrugEntry(
inn=inn,
aliases=d.get("aliases", []),
drug_class=d.get("class", ""),
classes=sorted(set(d.get("classes", [])) | set(classes_of.get(inn, []))),
contraindications=d.get("contraindications", []),
cautions=d.get("cautions", []),
interactions=d.get("interactions", []),
composition=d.get("composition"),
pregnancy=d.get("pregnancy", ""),
source=d.get("source", ""),
)
drugs[inn] = entry
return SafetyDb(drugs=drugs, allergy_labels=raw.get("allergy_labels") or {})
def find_drug(name: str, db: SafetyDb | None = None) -> DrugEntry | None:
"""By INN or alias, case-insensitive; "сертралин 50 мг" → сертралин."""
db = db or load_db()
words = [w.strip(".,!?;:«»()") for w in name.lower().replace("ё", "е").split()]
for entry in db.drugs.values():
names = {entry.inn, *(a.lower() for a in entry.aliases)}
# Russian case endings: "сертралине", "ибупрофена" — match on the stem (name minus the last letter).
if any(w in names or any(len(n) > 4 and w.startswith(n[:-1]) for n in names) for w in words):
return entry
return None
def check_patient(drug: DrugEntry, record: PatientRecord, db: SafetyDb | None = None) -> list[Finding]:
"""Patient-specific findings for `drug`, most severe first. Empty list = nothing found in the reference —
which is NOT a statement that the drug is safe (the answer must say so)."""
db = db or load_db()
out: list[Finding] = []
cond_tags = {t for c in record.conditions for t in c.tags}
for c in drug.cautions:
if c.when_condition in cond_tags:
out.append(Finding(severity=c.severity, kind="condition", text=c.text))
# Excipients vs allergies / intolerances.
allergy_tags = {t for a in record.allergies for t in a.tags}
if drug.composition is not None:
for tag in sorted(allergy_tags & set(drug.composition.excipient_tags)):
label = db.allergy_labels.get(tag, tag)
out.append(
Finding(
severity="high", kind="allergy", text=f"В составе есть {label}, а в карте отмечена непереносимость."
)
)
# Interactions with the patient's other medicines and with the current psychiatric prescription.
other_classes: dict[str, str] = {}
for m in record.other_medications:
for cls in m.classes:
other_classes[cls] = m.drug
for rx in record.current_prescriptions:
entry = find_drug(rx.drug, db)
if entry is not None and entry.inn != drug.inn:
for cls in entry.classes:
other_classes.setdefault(cls, rx.drug)
for i in drug.interactions:
if i.with_class in other_classes:
out.append(
Finding(
severity=i.severity, kind="interaction", text=f"{i.text} (в карте: {other_classes[i.with_class]})."
)
)
if record.pregnancy and drug.pregnancy:
out.append(Finding(severity="medium", kind="pregnancy", text=drug.pregnancy))
order = {"high": 0, "medium": 1, "low": 2}
out.sort(key=lambda f: order[f.severity])
return out
+31
View File
@@ -0,0 +1,31 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано».
Запрещено:
- ставить или называть диагнозы («у вас депрессия»), даже если они есть в карте;
- назначать, отменять препараты, менять дозы, советовать «примите/не принимайте» — решение всегда за врачом;
- называть дозировки в миллиграммах;
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён."""
+179
View File
@@ -0,0 +1,179 @@
"""Tool-calling chat providers for the agent.
* `OpenRouterProvider` — OpenAI-compatible `/chat/completions` with `tools`. TEMPORARY for the prototype on
synthetic data: OpenRouter is a foreign service, forbidden for real PII (152-FZ art. 18 p. 5). Replace with
GigaChat (function calling) before any real patient — same `ToolChatProvider` contract.
* `MockAgentProvider` — offline, deterministic: plans tool calls from keywords and composes an answer from the
tool results. Keeps the demo working without network (ADR-004).
"""
import json
import logging
import re
from typing import Any, Protocol
import httpx
from pydantic import BaseModel, Field
from app.config import Settings
log = logging.getLogger(__name__)
class ToolCall(BaseModel):
id: str
name: str
arguments: dict[str, Any] = Field(default_factory=dict)
class AssistantTurn(BaseModel):
content: str | None = None
tool_calls: list[ToolCall] = Field(default_factory=list)
class ToolChatProvider(Protocol):
name: str
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: ...
class ProviderError(RuntimeError):
pass
class OpenRouterProvider:
name = "openrouter"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
self._timeout = settings.chat_agent_timeout_s
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required" # never answer from the model's own memory: ground first
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
try:
if self._client is not None:
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
else:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200:
raise ProviderError(f"http {resp.status_code}")
try:
msg = resp.json()["choices"][0]["message"]
except (ValueError, KeyError, IndexError) as exc:
raise ProviderError("bad response") from exc
calls = []
for c in msg.get("tool_calls") or []:
fn = c.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=c.get("id") or fn.get("name", "call"), name=fn.get("name", ""), arguments=args))
usage = resp.json().get("usage") or {}
log.info("openrouter ok calls=%d tokens=%s", len(calls), usage.get("total_tokens"))
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
r"\b(сертралин\w*|пароксетин\w*|эсциталопрам\w*|флуоксетин\w*|ибупрофен\w*|нурофен\w*|золофт\w*)", re.I
)
_MED_HINT = re.compile(r"(таблет|лекарств|препарат|состав|можно ли|совмест|вместе|противопоказ|аллерг|лактоз)", re.I)
class MockAgentProvider:
"""Plans: record → (drug check for each mentioned drug, or the current prescription for medicine questions)
→ guidelines search; then writes a short answer strictly from the tool results."""
name = "mock"
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
question = next(m["content"] for m in reversed(messages) if m["role"] == "user")
done = [m for m in messages if m["role"] == "tool"]
if not done:
drugs = [m.group(1).lower() for m in _DRUG_WORDS.finditer(question)]
calls = [ToolCall(id="c0", name="get_patient_record")]
calls += [
ToolCall(id=f"d{i}", name="check_drug_for_patient", arguments={"drug": d}) for i, d in enumerate(drugs)
]
if drugs and re.search(r"состав|лактоз|желатин|аллерг|непереносим", question, re.I):
calls += [
ToolCall(id=f"i{i}", name="get_drug_info", arguments={"drug": d}) for i, d in enumerate(drugs)
]
calls.append(ToolCall(id="s0", name="search_guidelines", arguments={"query": question}))
return AssistantTurn(tool_calls=calls)
mine = re.search(r"мо(и|ими|их|ей|я|ё) (таблет|лекарств|препарат)", question, re.I)
checked = [m for m in done if m.get("name") == "check_drug_for_patient"]
if (not checked or mine) and len(checked) < 3 and _MED_HINT.search(question):
record = _tool_json(done, "get_patient_record")
rx = (record or {}).get("current_prescriptions") or []
checked_args = {str(json.loads(m["content"]).get("inn", "")) for m in checked}
rx = [r for r in rx if not any(r["drug"].lower().startswith(c[:-1]) for c in checked_args if c)]
if rx:
return AssistantTurn(
tool_calls=[ToolCall(id="d9", name="check_drug_for_patient", arguments={"drug": rx[0]["drug"]})]
)
return AssistantTurn(content=_compose(done))
def _tool_json(done: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
for m in done:
if m.get("name") == name:
try:
return json.loads(m["content"])
except json.JSONDecodeError:
return None
return None
def _compose(done: list[dict[str, Any]]) -> str:
parts: list[str] = []
seen: set[str] = set()
for m in done:
if m.get("name") != "check_drug_for_patient":
continue
data = json.loads(m["content"])
if not data.get("found") or data["inn"] in seen:
continue
seen.add(data["inn"])
findings = data.get("findings") or []
if findings:
parts.append(f"По вашей карте и инструкции к препарату «{data['inn']}» есть важное:")
parts += [f"• {f['text']}" for f in findings[:3]]
else:
parts.append(f"В справочнике не нашлось особых ограничений по «{data['inn']}» с учётом вашей карты.")
for m in done:
if m.get("name") != "get_drug_info":
continue
data = json.loads(m["content"])
comp = data.get("composition") if data.get("found") else None
if comp:
record = _tool_json(done, "get_patient_record") or {}
allergies = record.get("allergies") or []
if allergies:
names = ", ".join(a["name"] for a in allergies)
hit = {t for a in allergies for t in a.get("tags", [])} & set(comp.get("excipient_tags", []))
verdict = "это вещество есть" if hit else "этого вещества нет, но состав зависит от производителя"
parts.append(f"В вашей карте: {names}. В примере состава «{data['inn']}» {verdict}.")
excipients = ", ".join(comp["excipients"])
parts.append(f"Состав ({comp['form']}): {comp['active']}; вспомогательные: {excipients}. {comp['note']}")
search = _tool_json(done, "search_guidelines") or {}
results = search.get("results") or []
if results and not parts: # medicine findings already answer the question; a KR quote would dilute it
text = results[0]["text"].strip()
parts.append(f"В клинических рекомендациях: «{text[:400]}{'…' if len(text) > 400 else ''}»")
if not parts:
return ""
parts.append("Любые изменения в лечении обсуждайте с вашим врачом.")
return "\n".join(parts)
+200
View File
@@ -0,0 +1,200 @@
"""Read-only tools of the chat agent. Every tool is bound to the current patient's token on the server side:
the model never passes a token, so it cannot read another patient's data (medical secrecy, 323-FZ art. 13).
Tool results contain clinical data only (no name, SNILS, phone) — they are sent to the LLM provider.
"""
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve
from app.llm.rag.index import sentences
log = logging.getLogger(__name__)
RAG_TOP_K = 4
PASSAGE_CHARS = 700
# Patients never get dosages (SPEC §7): sentences with doses are dropped before the text reaches the LLM.
_DOSE = re.compile(r"\d\s*(?:-|–)?\s*\d*\s*(?:мг|mg|мкг|г/сут|таблетк)|\bдоз(?:а|у|ы|е|ой|ировк)", re.I)
TOOL_SPECS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "search_guidelines",
"description": (
"Поиск по базе знаний: клинические рекомендации Минздрава РФ (депрессия 2024, ПТСР 2023). "
"Возвращает фрагменты с источником и страницей. Используй для любых общих вопросов о состоянии, "
"лечении, сроках действия терапии, побочных эффектах, отмене, образе жизни."
),
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "Поисковый запрос на русском"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_patient_record",
"description": (
"Медицинская карта текущего пациента из ЕМИАС: диагнозы (МКБ-10), сопутствующие заболевания, "
"аллергии и непереносимости, назначения других врачей и текущее назначение психиатра. "
"Вызывай, когда ответ зависит от особенностей пациента или его лекарств."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "get_drug_info",
"description": (
"Справка о препарате по инструкции ГРЛС: состав (действующее и вспомогательные вещества), "
"противопоказания, взаимодействия, беременность. Аргумент — название препарата (МНН или торговое)."
),
"parameters": {
"type": "object",
"properties": {"drug": {"type": "string"}},
"required": ["drug"],
},
},
},
{
"type": "function",
"function": {
"name": "check_drug_for_patient",
"description": (
"Детерминированная проверка препарата для текущего пациента: сопутствующие заболевания, "
"непереносимости по составу, взаимодействия с другими его лекарствами. Используй перед любым "
"ответом о том, подходит ли пациенту препарат или сочетание препаратов."
),
"parameters": {
"type": "object",
"properties": {
"drug": {"type": "string", "description": "Название препарата; пусто — текущее назначение пациента"}
},
},
},
},
]
@dataclass
class ToolContext:
token: str
sources: list[str] = field(default_factory=list) # citations collected for the answer footer
used: list[str] = field(default_factory=list)
_record: PatientRecord | None = None
_record_loaded: bool = False
async def record(self) -> PatientRecord | None:
if not self._record_loaded:
self._record = await emias_records.get(self.token)
self._record_loaded = True
return self._record
def cite(self, label: str) -> None:
if label and label not in self.sources:
self.sources.append(label)
async def run_tool(ctx: ToolContext, name: str, args: dict[str, Any]) -> str:
"""Execute a tool; always returns a JSON string (errors included) so the model can recover."""
ctx.used.append(name)
log.info("agent tool=%s", name) # no arguments: they may echo patient text
try:
if name == "search_guidelines":
return _json(_search(ctx, str(args.get("query", ""))))
if name == "get_patient_record":
rec = await ctx.record()
if rec is None:
return _json({"found": False})
ctx.cite("ЕМИАС: медицинская карта")
return _json({"found": True, **rec.model_dump()})
if name == "get_drug_info":
return _json(_drug_info(ctx, str(args.get("drug", ""))))
if name == "check_drug_for_patient":
return _json(await _check(ctx, str(args.get("drug", ""))))
return _json({"error": f"unknown tool {name}"})
except Exception as exc: # noqa: BLE001 — a tool failure must not crash the chat
log.error("agent tool=%s failed: %s", name, type(exc).__name__)
return _json({"error": "tool failed"})
def _search(ctx: ToolContext, query: str) -> dict[str, Any]:
hits = retrieve(query, audience="patient", top_k=RAG_TOP_K) if query.strip() else []
out = []
for h in hits:
label = h.chunk.citation.strip("[]")
ctx.cite(label)
text = " ".join(x for x in sentences(h.passage) if not _DOSE.search(x))[:PASSAGE_CHARS]
if text:
out.append({"source": label, "section": h.chunk.section, "text": text})
return {"results": out, "found": bool(out)}
def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name)
if entry is None:
return {"found": False, "drug": name}
ctx.cite(entry.source)
return {
"found": True,
"inn": entry.inn,
"class": entry.drug_class,
"composition": entry.composition.model_dump() if entry.composition else None,
"contraindications": entry.contraindications,
"interactions": [i.text for i in entry.interactions],
"pregnancy": entry.pregnancy,
"source": entry.source,
}
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name) if name.strip() else None
if entry is None:
# "мои таблетки" / no name → the current psychiatric prescription from the record.
rec = await ctx.record()
current = [find_drug(p.drug) for p in (rec.current_prescriptions if rec else [])]
entry = next((e for e in current if e is not None), None) if not find_drug(name) else None
if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record()
if rec is None:
return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта")
db = load_db()
findings = check_patient(entry, rec, db)
comp = entry.composition
intolerances = [a.name for a in rec.allergies]
intolerance_check = None
if comp is not None and intolerances:
hit = {t for a in rec.allergies for t in a.tags} & set(comp.excipient_tags)
intolerance_check = f"Непереносимость из карты ({', '.join(intolerances)}): " + (
"вещество ЕСТЬ в составе."
if hit
else "в примере состава этого вещества нет; состав зависит от производителя."
)
return {
"found": True,
"inn": entry.inn,
"record": True,
"findings": [f.model_dump() for f in findings],
"intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
"general_interactions": [i.text for i in entry.interactions], # incl. alcohol — for questions about it
"contraindications": entry.contraindications,
"note": "Пустой findings не означает, что препарат безопасен: решение о приёме принимает врач.",
}
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
+61 -8
View File
@@ -9,7 +9,8 @@ The txt files come from `docs/source/rag/pdf_to_txt.py`: paragraphs are separate
* "Комментарии: ..." after a recommendation stays with the recommendation (the comment explains it); * "Комментарии: ..." after a recommendation stays with the recommendation (the comment explains it);
* a line starting in lowercase continues the previous paragraph (cut by a page break the converter missed); * a line starting in lowercase continues the previous paragraph (cut by a page break the converter missed);
* tables are split by rows, every table piece repeats the table header, so a chunk is readable alone; * tables are split by rows, every table piece repeats the table header, so a chunk is readable alone;
* each chunk keeps the page range it came from, for the citation "[КР ПТСР 2023, стр. 45]". * each chunk keeps the page range it came from, for the citation "[КР ПТСР 2023, стр. 45]"; inline
"⟦стр. N⟧" markers (paragraph glued across a page break) are removed from the text but move the range.
""" """
import re import re
@@ -25,6 +26,8 @@ from app.config import get_settings
Audience = str # "doctor" | "patient" | "team" Audience = str # "doctor" | "patient" | "team"
PAGE_MARKER = re.compile(r"^--- стр\. (\d+) ---$") PAGE_MARKER = re.compile(r"^--- стр\. (\d+) ---$")
# A paragraph that flows onto the next page is glued by the converter; "⟦стр. N⟧" marks where page N begins.
INLINE_PAGE = re.compile(r"\s*⟦стр\. (\d+)⟧\s*")
TABLE_DIVIDER = re.compile(r"^\|(-+\|)+$") TABLE_DIVIDER = re.compile(r"^\|(-+\|)+$")
# "2.1 Жалобы и анамнез", "3.1.2. Купирующая терапия ..." — a numbered subsection title. # "2.1 Жалобы и анамнез", "3.1.2. Купирующая терапия ..." — a numbered subsection title.
SUBSECTION = re.compile(r"^(\d{1,2}(?:\.\d{1,2})+)\.?\s+([А-ЯЁA-Z].*)$") # not dates "28.07.2022 N ..." SUBSECTION = re.compile(r"^(\d{1,2}(?:\.\d{1,2})+)\.?\s+([А-ЯЁA-Z].*)$") # not dates "28.07.2022 N ..."
@@ -77,6 +80,9 @@ class RetrievalConfig(BaseModel):
window_weight: float = 0.5 window_weight: float = 0.5
# section title prefix -> score multiplier, per audience ({"patient": {"Приложение В": 1.5}}) # section title prefix -> score multiplier, per audience ({"patient": {"Приложение В": 1.5}})
section_boost: dict[str, dict[str, float]] = Field(default_factory=dict) section_boost: dict[str, dict[str, float]] = Field(default_factory=dict)
# doc id -> regexes naming its disease; a question naming one disease favours that document
doc_cues: dict[str, list[str]] = Field(default_factory=dict)
doc_cue_boost: float = 1.0
class AnswerConfig(BaseModel): class AnswerConfig(BaseModel):
@@ -120,8 +126,46 @@ class _Unit:
"""Indivisible piece of a chunk: a paragraph (with its comment) or one table row.""" """Indivisible piece of a chunk: a paragraph (with its comment) or one table row."""
text: str text: str
page: int page: int # page where the unit starts
table_header: str | None = None # "| a | b |\n|---|---|" for table rows table_header: str | None = None # "| a | b |\n|---|---|" for table rows
breaks: list[tuple[int, int]] = field(default_factory=list) # (offset in text, page starting there)
@property
def page_end(self) -> int:
return self.breaks[-1][1] if self.breaks else self.page
def page_at(self, offset: int) -> int:
page = self.page
for start, p in self.breaks:
if start <= offset:
page = p
return page
def append(self, line: str, page: int, sep: str) -> None:
"""Glue a continuation line; record a page break if it comes from a later page."""
base = len(self.text) + len(sep)
text, breaks = _strip_inline_pages(line)
if page > self.page_end:
self.breaks.append((base, page))
self.breaks.extend((base + off, p) for off, p in breaks)
self.text += sep + text
def _strip_inline_pages(line: str) -> tuple[str, list[tuple[int, int]]]:
"""Text without "⟦стр. N⟧" markers + (offset, page) of each marker in the cleaned text."""
out, breaks, pos = [], [], 0
for m in INLINE_PAGE.finditer(line):
out.append(line[pos : m.start()])
breaks.append((sum(len(x) for x in out) + 1, int(m.group(1))))
out.append(" ")
pos = m.end()
out.append(line[pos:])
return "".join(out), breaks
def _new_unit(line: str, page: int) -> _Unit:
text, breaks = _strip_inline_pages(line)
return _Unit(text, page, breaks=breaks)
@dataclass @dataclass
@@ -185,7 +229,7 @@ def _sections(text: str, skip: list[str]) -> list[_Section]:
continue continue
if first.startswith("[Изображение"): if first.startswith("[Изображение"):
if not skipping: # transcribed figure: one unit, keeps its own line breaks if not skipping: # transcribed figure: one unit, keeps its own line breaks
sections[-1].units.append(_Unit("\n".join(block[1:]) if len(block) > 1 else first, page)) sections[-1].units.append(_new_unit("\n".join(block[1:]) if len(block) > 1 else first, page))
continue continue
for j, line in enumerate(block): for j, line in enumerate(block):
title = heading(line) title = heading(line)
@@ -199,11 +243,11 @@ def _sections(text: str, skip: list[str]) -> list[_Section]:
continue continue
units = sections[-1].units units = sections[-1].units
if COMMENT.match(line) and units and units[-1].table_header is None: if COMMENT.match(line) and units and units[-1].table_header is None:
units[-1].text += "\n" + line units[-1].append(line, page, "\n")
elif line[0].islower() and units and units[-1].table_header is None: elif line[0].islower() and units and units[-1].table_header is None:
units[-1].text += " " + line # paragraph continued after a page break or a figure units[-1].append(line, page, " ") # paragraph continued after a page break or a figure
else: else:
units.append(_Unit(line, page)) units.append(_new_unit(line, page))
return [s for s in sections if s.units] return [s for s in sections if s.units]
@@ -236,7 +280,16 @@ def _split_long(unit: _Unit, max_chars: int) -> list[_Unit]:
parts[-1] += " " + sentence parts[-1] += " " + sentence
else: else:
parts.append(sentence) parts.append(sentence)
return [_Unit(p, unit.page) for p in parts] out: list[_Unit] = []
pos = 0
for part in parts:
start = unit.text.find(part[:40], pos)
start = pos if start < 0 else start
end = start + len(part) - 1
first, last = unit.page_at(start), unit.page_at(end)
out.append(_Unit(part, first, breaks=[(len(part) - 1, last)] if last != first else []))
pos = start + len(part)
return out
def _render(units: list[_Unit]) -> str: def _render(units: list[_Unit]) -> str:
@@ -280,7 +333,7 @@ def chunk_document(doc: DocumentMeta, text: str, cfg: ChunkingConfig) -> list[Ch
doc=doc, doc=doc,
section=section.title, section=section.title,
page_start=group[0].page, page_start=group[0].page,
page_end=group[-1].page, page_end=max(u.page_end for u in group),
text=_render(group), text=_render(group),
) )
) )
+22 -5
View File
@@ -20,6 +20,8 @@ from app.llm.rag.corpus import Audience, Chunk, RagConfig, load_chunks, load_con
from app.llm.rag.stemmer import stem from app.llm.rag.stemmer import stem
_WORD = re.compile(r"[а-яёa-z0-9]+(?:-[а-яёa-z0-9]+)*", re.IGNORECASE) _WORD = re.compile(r"[а-яёa-z0-9]+(?:-[а-яёa-z0-9]+)*", re.IGNORECASE)
# `privacy.scrub()` placeholders ("[NAME]", "[PHONE]") are not words of the question.
_PLACEHOLDER = re.compile(r"\[[A-Z_]+\]")
# Function words and generic KR boilerplate that carry no topical signal. # Function words and generic KR boilerplate that carry no topical signal.
STOP_WORDS = frozenset( STOP_WORDS = frozenset(
@@ -36,7 +38,7 @@ STOP_WORDS = frozenset(
def tokens(text: str) -> list[str]: def tokens(text: str) -> list[str]:
"""Stems of content words (hyphenated words are kept whole and also split: "когнитивно-поведенческая").""" """Stems of content words (hyphenated words are kept whole and also split: "когнитивно-поведенческая")."""
out: list[str] = [] out: list[str] = []
for word in _WORD.findall(text.lower().replace("ё", "е")): for word in _WORD.findall(_PLACEHOLDER.sub(" ", text).lower().replace("ё", "е")):
parts = [p for p in word.split("-") if p not in STOP_WORDS and (len(p) > 1 or p.isdigit())] parts = [p for p in word.split("-") if p not in STOP_WORDS and (len(p) > 1 or p.isdigit())]
if "-" in word and len(parts) > 1: if "-" in word and len(parts) > 1:
out.append("-".join(stem(p) for p in parts)) # the compound itself, in any case form out.append("-".join(stem(p) for p in parts)) # the compound itself, in any case form
@@ -97,6 +99,8 @@ class BM25Index:
self._k1, self._b, self._min_score, self._top_k = r.bm25_k1, r.bm25_b, r.min_score, r.top_k self._k1, self._b, self._min_score, self._top_k = r.bm25_k1, r.bm25_b, r.min_score, r.top_k
self._candidates, self._window, self._window_weight = r.rerank_candidates, r.window_sentences, r.window_weight self._candidates, self._window, self._window_weight = r.rerank_candidates, r.window_sentences, r.window_weight
self._section_boost = r.section_boost self._section_boost = r.section_boost
self._doc_cues = {doc: re.compile("|".join(cues), re.IGNORECASE) for doc, cues in r.doc_cues.items() if cues}
self._doc_cue_boost = r.doc_cue_boost
self.chunks = chunks self.chunks = chunks
self._sentences = [[(s, set(tokens(s))) for s in sentences(c.text)] for c in chunks] self._sentences = [[(s, set(tokens(s))) for s in sentences(c.text)] for c in chunks]
self._expander = SynonymExpander(cfg.synonyms) self._expander = SynonymExpander(cfg.synonyms)
@@ -124,10 +128,20 @@ class BM25Index:
score += self._idf[term] * f * (self._k1 + 1) / (f + norm) * weight score += self._idf[term] * f * (self._k1 + 1) / (f + norm) * weight
return score return score
def boost(self, chunk: Chunk, audience: Audience | None) -> float: def boost(self, chunk: Chunk, audience: Audience | None, cued: set[str] | None = None) -> float:
"""Section multiplier for the audience (`retrieval.section_boost`), 1.0 by default.""" """Section multiplier for the audience (`retrieval.section_boost`) × disease-cue multiplier."""
boosts = self._section_boost.get(audience or "", {}) boosts = self._section_boost.get(audience or "", {})
return next((b for prefix, b in boosts.items() if chunk.section.startswith(prefix)), 1.0) section = next((b for prefix, b in boosts.items() if chunk.section.startswith(prefix)), 1.0)
return section * (self._doc_cue_boost if cued and chunk.doc.id in cued else 1.0)
def cued_docs(self, query: str) -> set[str]:
"""Documents whose disease the query names ("ТМС при депрессии" -> {"kr_depression"}).
Only a one-sided mention counts: a question naming both disorders favours neither.
"""
q = _PLACEHOLDER.sub(" ", query).replace("ё", "е")
cued = {doc for doc, cue in self._doc_cues.items() if cue.search(q)}
return cued if len(cued) < len(self._doc_cues) else set()
def overlap(self, text: str, terms: dict[str, float]) -> float: def overlap(self, text: str, terms: dict[str, float]) -> float:
"""Sum of idf × weight of the query terms present in `text` (sentence relevance for `answer()`).""" """Sum of idf × weight of the query terms present in `text` (sentence relevance for `answer()`)."""
@@ -193,11 +207,14 @@ class BM25Index:
and (s := self._score(i, terms)) >= threshold and (s := self._score(i, terms)) >= threshold
] ]
scored.sort(key=lambda x: (-x[0], x[1])) scored.sort(key=lambda x: (-x[0], x[1]))
cued = self.cued_docs(query)
hits = [] hits = []
for bm25, i in scored[: max(self._candidates, top_k or 0)]: for bm25, i in scored[: max(self._candidates, top_k or 0)]:
bonus, passage = self._passage(i, terms) bonus, passage = self._passage(i, terms)
chunk = self.chunks[i] chunk = self.chunks[i]
hits.append(Hit(chunk, (bm25 + self._window_weight * bonus) * self.boost(chunk, audience), bm25, passage)) hits.append(
Hit(chunk, (bm25 + self._window_weight * bonus) * self.boost(chunk, audience, cued), bm25, passage)
)
hits.sort(key=lambda h: -h.score) # stable: BM25 order breaks ties hits.sort(key=lambda h: -h.score) # stable: BM25 order breaks ties
return hits[: top_k or self._top_k] return hits[: top_k or self._top_k]
+6 -3
View File
@@ -56,6 +56,7 @@ PRESCRIBING = re.compile(
) )
# "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred. # "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred.
DEFINITION_QUESTION = re.compile(r"^\s*что (?:такое|значит|означает|это за)\s+(.+?)[?.!\s]*$", re.IGNORECASE) DEFINITION_QUESTION = re.compile(r"^\s*что (?:такое|значит|означает|это за)\s+(.+?)[?.!\s]*$", re.IGNORECASE)
LEADING_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+") # "[NAME], что такое ПТСР?"
DEFINITION_HEAD = re.compile(r"\s[–—-]\s(?![^(]*\))|\s[–—-]?\s?это\s") # dash outside "(… – англ.)", or "это" DEFINITION_HEAD = re.compile(r"\s[–—-]\s(?![^(]*\))|\s[–—-]?\s?это\s") # dash outside "(… – англ.)", or "это"
DEFINITION_HEAD_CHARS = 60 DEFINITION_HEAD_CHARS = 60
DEFINITION_BONUS = 2.0 # multiplier for a sentence that starts with the asked term (picks it within a chunk) DEFINITION_BONUS = 2.0 # multiplier for a sentence that starts with the asked term (picks it within a chunk)
@@ -174,7 +175,7 @@ def _allowed(sentence: str | None, audience: Audience) -> bool:
def _defined_term(question: str) -> set[str] | None: def _defined_term(question: str) -> set[str] | None:
m = DEFINITION_QUESTION.match(question) m = DEFINITION_QUESTION.match(LEADING_PLACEHOLDERS.sub("", question))
return set(tokens(m.group(1))) or None if m else None return set(tokens(m.group(1))) or None if m else None
@@ -198,6 +199,7 @@ def best_passage(
audience: Audience, audience: Audience,
cfg: AnswerConfig, cfg: AnswerConfig,
defined: set[str] | None = None, defined: set[str] | None = None,
cued: set[str] | None = None,
) -> Passage | None: ) -> Passage | None:
"""Best sentence of the chunk extended by adjacent relevant sentences (contiguous, within limits).""" """Best sentence of the chunk extended by adjacent relevant sentences (contiguous, within limits)."""
sents = [s if _allowed(s, audience) else None for s in chunk_sentences(chunk, clinical=audience != "patient")] sents = [s if _allowed(s, audience) else None for s in chunk_sentences(chunk, clinical=audience != "patient")]
@@ -235,7 +237,7 @@ def best_passage(
size += length(nxt) size += length(nxt)
text = " ".join(s for s in sents[lo : hi + 1] if s) text = " ".join(s for s in sents[lo : hi + 1] if s)
definition = scores[best] > index.overlap(head, terms) # got DEFINITION_BONUS definition = scores[best] > index.overlap(head, terms) # got DEFINITION_BONUS
return Passage(chunk, text, index.overlap(text, terms) * index.boost(chunk, audience), definition) return Passage(chunk, text, index.overlap(text, terms) * index.boost(chunk, audience, cued), definition)
def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | None = None) -> list[Passage]: def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | None = None) -> list[Passage]:
@@ -244,7 +246,8 @@ def select(index: BM25Index, question: str, audience: Audience, docs: set[str] |
hits = index.search(question, audience=audience, docs=docs, top_k=cfg.max_chunks) hits = index.search(question, audience=audience, docs=docs, top_k=cfg.max_chunks)
terms = index.query_terms(question) terms = index.query_terms(question)
defined = _defined_term(question) defined = _defined_term(question)
passages = [p for h in hits if (p := best_passage(index, h.chunk, terms, audience, cfg, defined))] cued = index.cued_docs(question)
passages = [p for h in hits if (p := best_passage(index, h.chunk, terms, audience, cfg, defined, cued))]
if not passages: if not passages:
return [] return []
# A definition answers "что такое X?" better than any passage that merely mentions X often. # A definition answers "что такое X?" better than any passage that merely mentions X often.
+29 -29
View File
@@ -73,7 +73,7 @@ F32/F33 со здоровьем:
Депрессивный эпизод (ДЭ) - аффективное расстройство, характеризующееся в типичных случаях снижением настроения, утратой интересов и удовольствия, снижением энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся сниженная способность к сосредоточению и вниманию; сниженные самооценка и чувство уверенности в себе; идеи виновности и уничижения (даже при легком типе эпизода); мрачное и пессимистическое видение будущего; идеи или действия, направленные на самоповреждение или суицид; нарушенный сон; сниженный аппетит [1]. Депрессивный эпизод (ДЭ) - аффективное расстройство, характеризующееся в типичных случаях снижением настроения, утратой интересов и удовольствия, снижением энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся сниженная способность к сосредоточению и вниманию; сниженные самооценка и чувство уверенности в себе; идеи виновности и уничижения (даже при легком типе эпизода); мрачное и пессимистическое видение будущего; идеи или действия, направленные на самоповреждение или суицид; нарушенный сон; сниженный аппетит [1].
Рекуррентное депрессивное расстройство (РДР) - аффективное расстройство, характеризующееся повторными эпизодами депрессий, как указано в F32, без анамнестических данных об отдельных эпизодах приподнятого настроения, гиперактивности, которые могли отвечать критериям гипоманиакального, маниакального или смешанного аффективного эпизода (F30.-) [1]. Рекуррентное депрессивное расстройство (РДР) - аффективное расстройство, характеризующееся повторными эпизодами депрессий, как указано в F32, без анамнестических данных об отдельных эпизодах приподнятого настроения, гиперактивности, которые могли отвечать критериям гипоманиакального, маниакального или смешанного аффективного эпизода (F30.-) [1].
1.2 Этиология и патогенез заболевания или состояния (группы заболеваний или состояний) 1.2 Этиология и патогенез заболевания или состояния (группы заболеваний или состояний)
Этиология и патогенез ДЭ и РДР до настоящего времени окончательно не установлены. По всей видимости, возникновение депрессивных расстройств детерминировано сочетанием множества факторов: биологических (конституционально-генетических), психологических и социальных. Среди биологических концепций традиционной является моноаминовая теория депрессии, согласно которой основным патогенетическим механизмом ее возникновения является недостаточность синаптической активности моноаминов (серотонина, дофамина, норадреналина) головного мозга. Как значимые рассматриваются и другие патофизиологические процессы: проявления психосоциального и биологического дистресса, хронобиологическая гипотеза, нарушение процессов нейропластичности, дисрегуляция гипоталамо-гипофизарно-надпочечниковой оси, синтеза нейротрофинов, транскрипционного фактора CREB, а также нейроанатомические особенности гиппокампа, миндалевидного тела (амигдалы), прилежащего ядра и других областей мозга [2, 3, 4]. Этиология и патогенез ДЭ и РДР до настоящего времени окончательно не установлены. По всей видимости, возникновение депрессивных расстройств детерминировано сочетанием множества факторов: биологических (конституционально-генетических), психологических и социальных. Среди биологических концепций традиционной является моноаминовая теория депрессии, согласно которой основным патогенетическим механизмом ее возникновения является недостаточность синаптической активности моноаминов (серотонина, дофамина, норадреналина) головного мозга. Как значимые рассматриваются и другие патофизиологические процессы: проявления психосоциального и биологического дистресса, хронобиологическая гипотеза, нарушение процессов нейропластичности, дисрегуляция гипоталамо-гипофизарно-надпочечниковой оси, синтеза нейротрофинов, транскрипционного фактора CREB, а также нейроанатомические особенности гиппокампа, ⟦стр. 3⟧ миндалевидного тела (амигдалы), прилежащего ядра и других областей мозга [2, 3, 4].
--- стр. 3 --- --- стр. 3 ---
@@ -125,7 +125,7 @@ F33.9 - Рекуррентное депрессивное расстройств
В типичных случаях больной с ДЭ страдает от сниженного настроения, утраты интересов и удовольствия, снижения энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся: сниженная способность к сосредоточению и вниманию, сниженные самооценка и чувство уверенности в себе, идеи виновности и уничижения (даже при легком типе эпизода), мрачное и пессимистическое видение будущего, идеи или действия, направленные на самоповреждение или суицид, нарушенный сон, сниженный аппетит. В типичных случаях больной с ДЭ страдает от сниженного настроения, утраты интересов и удовольствия, снижения энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся: сниженная способность к сосредоточению и вниманию, сниженные самооценка и чувство уверенности в себе, идеи виновности и уничижения (даже при легком типе эпизода), мрачное и пессимистическое видение будущего, идеи или действия, направленные на самоповреждение или суицид, нарушенный сон, сниженный аппетит.
Сниженное настроение мало колеблется в течение дней, и часто нет реакции на окружающие обстоятельства, но могут быть характерные суточные колебания. В некоторых случаях тревога, отчаяние и двигательная ажитация временами могут быть более выраженными, чем депрессия, а изменения настроения также могут быть маскированы дополнительными симптомами: раздражительностью, чрезмерным употреблением алкоголя, истерическим поведением, обострением предшествующих фобических или навязчивых симптомов, ипохондрическими идеями. Сниженное настроение мало колеблется в течение дней, и часто нет реакции на окружающие обстоятельства, но могут быть характерные суточные колебания. В некоторых случаях тревога, отчаяние и двигательная ажитация временами могут быть более выраженными, чем депрессия, а изменения настроения также могут быть маскированы дополнительными симптомами: раздражительностью, чрезмерным употреблением алкоголя, истерическим поведением, обострением предшествующих фобических или навязчивых симптомов, ипохондрическими идеями.
Некоторые из вышеуказанных симптомов могут быть выраженными и обнаруживают характерные признаки, которые считаются как имеющие специальное клиническое значение. Наиболее типичными примерами являются соматические и психотические симптомы. Некоторые из вышеуказанных симптомов могут быть выраженными и обнаруживают характерные признаки, которые считаются как имеющие специальное клиническое значение. Наиболее типичными примерами являются соматические и психотические симптомы.
В некоторых случаях в структуре депрессивного эпизода наблюдаются субсиндромальные маниакальные симптомы, независимо от того, возникают ли они в рамках БАР или РДР. МКБ-10 не разделяет смешанные состояния по доминирующему аффекту. В отличие от МКБ-10, в DSM-5 вводится уточняющий спецификатор "смешанные черты" и выделяются депрессивный эпизод со смешанными чертами и маниакальный эпизод со смешанными чертами. В некоторых случаях в структуре депрессивного эпизода наблюдаются субсиндромальные маниакальные симптомы, независимо от того, возникают ли они в рамках БАР или РДР. МКБ-10 не разделяет смешанные состояния по доминирующему аффекту. В отличие от МКБ-10, в DSM-5 вводится ⟦стр. 5⟧ уточняющий спецификатор "смешанные черты" и выделяются депрессивный эпизод со смешанными чертами и маниакальный эпизод со смешанными чертами.
--- стр. 5 --- --- стр. 5 ---
@@ -247,7 +247,7 @@ A. критерии F33 удовлетворяются для прошлых э
F33.8 Другие рекуррентные депрессивные расстройства F33.8 Другие рекуррентные депрессивные расстройства
F33.9 Рекуррентное депрессивное расстройство неуточненное F33.9 Рекуррентное депрессивное расстройство неуточненное
2.1 Жалобы и анамнез 2.1 Жалобы и анамнез
● Всем пациентам с целью первичной диагностики рекомендуется прием (осмотр, консультация) врача-психиатра первичный с психопатологическим обследованием и оценкой риска суицидального поведения [16, 17, 18, 19]. ● Всем пациентам с целью первичной диагностики рекомендуется прием (осмотр, ⟦стр. 8⟧ консультация) врача-психиатра первичный с психопатологическим обследованием и оценкой риска суицидального поведения [16, 17, 18, 19].
--- стр. 8 --- --- стр. 8 ---
@@ -273,7 +273,7 @@ F33.9 Рекуррентное депрессивное расстройство
- о наличии травм, инфекционных заболеваний головного мозга, соматических заболеваний, предшествовавших развитию депрессивных симптомов [36]; - о наличии травм, инфекционных заболеваний головного мозга, соматических заболеваний, предшествовавших развитию депрессивных симптомов [36];
- об употреблении психоактивных веществ или лекарственных препаратов, которые могут вызвать симптомы депрессии, непосредственно перед началом депрессивного эпизода [37, 38, 39, 40]. - об употреблении психоактивных веществ или лекарственных препаратов, которые могут вызвать симптомы депрессии, непосредственно перед началом депрессивного эпизода [37, 38, 39, 40].
Уровень убедительности рекомендаций С (уровень достоверности доказательств - 5). Уровень убедительности рекомендаций С (уровень достоверности доказательств - 5).
● Всем пациентам с диагностированным первичным или повторным депрессивным эпизодом, получающим терапию, рекомендуется прием (осмотр, консультация) врача-психиатра повторный с проведением оценки эффективности лечения [16, 17]. ● Всем пациентам с диагностированным первичным или повторным ⟦стр. 9⟧ депрессивным эпизодом, получающим терапию, рекомендуется прием (осмотр, консультация) врача-психиатра повторный с проведением оценки эффективности лечения [16, 17].
--- стр. 9 --- --- стр. 9 ---
@@ -296,7 +296,7 @@ F33.9 Рекуррентное депрессивное расстройство
(Human immunodeficiency virus HIV 2) в крови в рамках первичного обследования, при поступлении в стационар и далее каждые 12 месяцев для исключения ассоциации с (Human immunodeficiency virus HIV 2) в крови в рамках первичного обследования, при поступлении в стационар и далее каждые 12 месяцев для исключения ассоциации с
ВИЧ-инфекцией, гепатитом, сифилисом [52, 53, 54]. ВИЧ-инфекцией, гепатитом, сифилисом [52, 53, 54].
Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4). Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4).
● Пациентам с впервые диагностированным ДЭ или РДР либо при наличии клинических или анамнестических показаний с целью исключения патологии щитовидной железы рекомендуется проведение исследования уровня тиреотропного гормона (ТТГ) в крови, уровня свободного трийодтиронина (СТ3) в крови, уровня свободного тироксина (СТ4) сыворотки крови для оценки функции щитовидной железы [ ● Пациентам с впервые диагностированным ДЭ или РДР либо при наличии ⟦стр. 10⟧ клинических или анамнестических показаний с целью исключения патологии щитовидной железы рекомендуется проведение исследования уровня тиреотропного гормона (ТТГ) в крови, уровня свободного трийодтиронина (СТ3) в крови, уровня свободного тироксина (СТ4) сыворотки крови для оценки функции щитовидной железы [
--- стр. 10 --- --- стр. 10 ---
@@ -324,7 +324,7 @@ F33.9 Рекуррентное депрессивное расстройство
(см. приложение Г4). (см. приложение Г4).
● Пациентам ДЭ или РДР с сопутствующими расстройствами личности и/или дифференциально-диагностическими трудностями рекомендуется прием (тестирование, консультация) медицинского психолога первичный и проведение патопсихологического-экспериментального-психологического (психодиагностического) исследования [72]. ● Пациентам ДЭ или РДР с сопутствующими расстройствами личности и/или дифференциально-диагностическими трудностями рекомендуется прием (тестирование, консультация) медицинского психолога первичный и проведение патопсихологического-экспериментального-психологического (психодиагностического) исследования [72].
Уровень убедительности рекомендаций C (уровень достоверности доказательств - 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств - 5).
● Пациентам с ДЭ или РДР перед назначением терапии с целью проведения дифференциальной диагностики с другими заболеваниями, исключения противопоказаний к ряду препаратов, которые назначаются при ДЭ, при необходимости госпитализации и для оценки различных рисков соматическому здоровью рекомендуется прием (осмотр, консультация) врача-терапевта, прием (осмотр, консультация) врача-невролога, прием (осмотр, консультация) врача-офтальмолога, для женщин - прием (осмотр, консультация) врача-акушера-гинеколога; для женщин фертильного возраста, не использующих надежные методы контрацепции, рекомендуется проведение теста на беременность; для мужчин, предъявляющих жалобы на расстройства сексуальной сферы, рекомендуется прием (осмотр, консультация) врача-уролога [17, 36, 42, 43, 44, 73, 74]. ● Пациентам с ДЭ или РДР перед назначением терапии с целью проведения дифференциальной диагностики с другими заболеваниями, исключения противопоказаний к ряду препаратов, которые назначаются при ДЭ, при необходимости госпитализации и для оценки различных рисков соматическому здоровью ⟦стр. 11⟧ рекомендуется прием (осмотр, консультация) врача-терапевта, прием (осмотр, консультация) врача-невролога, прием (осмотр, консультация) врача-офтальмолога, для женщин - прием (осмотр, консультация) врача-акушера-гинеколога; для женщин фертильного возраста, не использующих надежные методы контрацепции, рекомендуется проведение теста на беременность; для мужчин, предъявляющих жалобы на расстройства сексуальной сферы, рекомендуется прием (осмотр, консультация) врача-уролога [17, 36, 42, 43, 44, 73, 74].
--- стр. 11 --- --- стр. 11 ---
@@ -360,7 +360,7 @@ F33.9 Рекуррентное депрессивное расстройство
Не существует доказательных данных о большей эффективности или более быстром начале действия АД какого-либо одного класса, хотя при тяжелых, требующих госпитализации, депрессиях некоторые ТЦА (амитриптилин** и кломипрамин**) и венлафаксин оказываются несколько эффективнее, чем селективные ингибиторы обратного захвата серотонина (СИОЗС) [82, 83]. В отношении АД второго поколения сравнительный метаанализ 117 РКИ, включавший 25928 больных, показал, что миртазапин, эсциталопрам, венлафаксин и сертралин** были несколько более эффективны, чем дулоксетин, флуоксетин**, флувоксамин и пароксетин** [84]. В более позднем метаанализе тех же авторов [85] на основе 522 РКИ с участием 116 477 человек и применением 21 антидепрессанта было показано, что показатели отношения шансов были достаточно близкими для различных антидепрессантов как при оценке эффективности, так и переносимости, с широкими доверительными интервалами в большинстве сравнительных анализов. При непосредственном сравнении агомелатин**, амитриптилин**, эсциталопрам, миртазапин, пароксетин**, венлафаксин, и вортиоксетин показали более высокую эффективность, чем другие антидепрессанты, тогда как флуоксетин**, флувоксамин и тразодон оказались менее эффективными препаратами. Что касается переносимости, агомелатин**, циталопрам, эсциталопрам, флуоксетин**, сертралин** и вортиоксетин переносились лучше, чем другие антидепрессанты. Не существует доказательных данных о большей эффективности или более быстром начале действия АД какого-либо одного класса, хотя при тяжелых, требующих госпитализации, депрессиях некоторые ТЦА (амитриптилин** и кломипрамин**) и венлафаксин оказываются несколько эффективнее, чем селективные ингибиторы обратного захвата серотонина (СИОЗС) [82, 83]. В отношении АД второго поколения сравнительный метаанализ 117 РКИ, включавший 25928 больных, показал, что миртазапин, эсциталопрам, венлафаксин и сертралин** были несколько более эффективны, чем дулоксетин, флуоксетин**, флувоксамин и пароксетин** [84]. В более позднем метаанализе тех же авторов [85] на основе 522 РКИ с участием 116 477 человек и применением 21 антидепрессанта было показано, что показатели отношения шансов были достаточно близкими для различных антидепрессантов как при оценке эффективности, так и переносимости, с широкими доверительными интервалами в большинстве сравнительных анализов. При непосредственном сравнении агомелатин**, амитриптилин**, эсциталопрам, миртазапин, пароксетин**, венлафаксин, и вортиоксетин показали более высокую эффективность, чем другие антидепрессанты, тогда как флуоксетин**, флувоксамин и тразодон оказались менее эффективными препаратами. Что касается переносимости, агомелатин**, циталопрам, эсциталопрам, флуоксетин**, сертралин** и вортиоксетин переносились лучше, чем другие антидепрессанты.
АД прежде всего различаются по профилю побочных эффектов, потенциалу взаимодействия с другими препаратами и опасности передозировки (Приложение А3, табл. 3) [86]. АД из классов АД прежде всего различаются по профилю побочных эффектов, потенциалу взаимодействия с другими препаратами и опасности передозировки (Приложение А3, табл. 3) [86]. АД из классов
СИОЗС, СИОЗСН, а также другие АД второго и третьего поколений в целом переносятся лучше, чем препараты первого поколения (ТЦА), что способствует уменьшению вероятности самостоятельного отказа пациентов от их приема. Для ТЦА наиболее характерны антихолинергические (сухость во рту, запоры, нарушение зрения, задержка мочи, тахикардия), сердечно-сосудистые ( α-адренергическая блокада, ортостатическая гипотензия, брадиаритмия, тахикардия), антигистаминные (седация) побочные эффекты, прибавка веса и неврологические симптомы (легкий миоклонус, судорожные состояния при передозировке, делирий у пожилых пациентов). СИОЗС, СИОЗСН, а также другие АД второго и третьего поколений в целом переносятся лучше, чем препараты первого поколения (ТЦА), что способствует уменьшению вероятности самостоятельного отказа пациентов от их приема. Для ТЦА наиболее характерны антихолинергические (сухость во рту, запоры, нарушение зрения, задержка мочи, тахикардия), сердечно-сосудистые ( α-адренергическая блокада, ортостатическая гипотензия, брадиаритмия, тахикардия), антигистаминные (седация) побочные эффекты, прибавка веса и неврологические симптомы (легкий миоклонус, судорожные состояния при передозировке, делирий у пожилых пациентов).
Учитывая, что СИОЗС обладают лучшей переносимостью по сравнению с ТЦА и редко оказывают свойственные ТЦА антихолинергическое и кардиотоксическое действие, они и другие антидепрессанты новых поколений являются препаратами первого выбора у больных с сердечно-сосудистыми заболеваниями, а также в амбулаторной практике. ТЦА не должны назначаться пациентам с сердечно-сосудистыми заболеваниями умеренной и выраженной тяжести, закрытоугольной глаукомой, гипертрофией простаты, с когнитивными нарушениями, судорожными состояниями и делириозными расстройствами. Учитывая, что СИОЗС обладают лучшей переносимостью по сравнению с ТЦА и редко оказывают свойственные ТЦА антихолинергическое и кардиотоксическое действие, они и другие антидепрессанты новых поколений являются препаратами первого выбора у больных с сердечно-сосудистыми заболеваниями, а также в амбулаторной практике. ТЦА не должны назначаться пациентам с сердечно-сосудистыми заболеваниями умеренной и выраженной тяжести, ⟦стр. 13⟧ закрытоугольной глаукомой, гипертрофией простаты, с когнитивными нарушениями, судорожными состояниями и делириозными расстройствами.
--- стр. 13 --- --- стр. 13 ---
@@ -383,7 +383,7 @@ F33.9 Рекуррентное депрессивное расстройство
, 99], агомелатина** [89, 90] и вортиоксетина [91]. , 99], агомелатина** [89, 90] и вортиоксетина [91].
Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1). Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1).
Комментарии: ТЦА при купирующей терапии ДЭ средней степени обычно рассматривают как препараты второго выбора в связи с менее благоприятным профилем переносимости, как отмечалось выше [83, 85]. К препаратам второго выбора также можно отнести тразодон и миансерин. Тразодон является специфическим серотонинергическим АД (в соответствии с АТХ другие антидепрессанты) и, вероятно, обладает сравнимой с СИОЗС эффективностью, но иным профилем побочных эффектов, а именно: при применении тразодона чаще встречаются седация и ортостатическая гипотензия, зато реже возникают сексуальные нарушения (однако в редких случаях возникает тяжелое осложнение в виде приапизма), бессонница, обострение тревоги и тошнота по сравнению с СИОЗС [100, 101]. Миансерин, как и родственный ему миртазапин, по механизму действия относится к норадренергическим и специфическим серотонинергическим антидепрессантам Комментарии: ТЦА при купирующей терапии ДЭ средней степени обычно рассматривают как препараты второго выбора в связи с менее благоприятным профилем переносимости, как отмечалось выше [83, 85]. К препаратам второго выбора также можно отнести тразодон и миансерин. Тразодон является специфическим серотонинергическим АД (в соответствии с АТХ другие антидепрессанты) и, вероятно, обладает сравнимой с СИОЗС эффективностью, но иным профилем побочных эффектов, а именно: при применении тразодона чаще встречаются седация и ортостатическая гипотензия, зато реже возникают сексуальные нарушения (однако в редких случаях возникает тяжелое осложнение в виде приапизма), бессонница, обострение тревоги и тошнота по сравнению с СИОЗС [100, 101]. Миансерин, как и родственный ему миртазапин, по механизму действия относится к норадренергическим и специфическим серотонинергическим антидепрессантам
(в соответствии с АТХ - другие антидепрессанты), обладает преимущественно седативным эффектом и имеет довольно слабую доказательную базу, хотя в нескольких публикациях 80-х годов по результатам РКИ сообщалось о его сходной с амитриптилином эффективности [102, 103]. Еще более слабой доказательной базой обладает обладают отечественный антидепрессант трициклической структуры пипофезин** (в соответствии с АТХ - другие антидепрессанты), по которому имеются лишь данные открытых исследований [104]. Эффективность другого отечественного препарата пирлиндола (в соответствии с АТХ - другие антидепрессанты), также изучалась только в открытых исследованиях [105]; кроме того, он, являясь по механизму действия ИМАО-А, может быть эффективен при лечении атипичных депрессий и на стадиях резистентности к терапии [106]. (в соответствии с АТХ - другие антидепрессанты), обладает преимущественно седативным эффектом и имеет довольно слабую доказательную базу, хотя в нескольких публикациях 80-х годов по результатам РКИ сообщалось о его сходной с амитриптилином эффективности [102, 103]. Еще более ⟦стр. 14⟧ слабой доказательной базой обладает обладают отечественный антидепрессант трициклической структуры пипофезин** (в соответствии с АТХ - другие антидепрессанты), по которому имеются лишь данные открытых исследований [104]. Эффективность другого отечественного препарата пирлиндола (в соответствии с АТХ - другие антидепрессанты), также изучалась только в открытых исследованиях [105]; кроме того, он, являясь по механизму действия ИМАО-А, может быть эффективен при лечении атипичных депрессий и на стадиях резистентности к терапии [106].
--- стр. 14 --- --- стр. 14 ---
@@ -411,7 +411,7 @@ F33.9 Рекуррентное депрессивное расстройство
133, 134, 135, 136, 137, 138, 139, 140, 141]. 133, 134, 135, 136, 137, 138, 139, 140, 141].
В настоящее время не существует каких-либо лекарственных препаратов, имеющих официальное разрешение к медицинскому применению при лечении ДЭ со смешанными чертами. В настоящее время не существует каких-либо лекарственных препаратов, имеющих официальное разрешение к медицинскому применению при лечении ДЭ со смешанными чертами.
Однако большинство АВП, хотя и не все, оказались эффективными при депрессии со смешанными чертами и/или при депрессии [142]. Большинство исследований, изучавших эффективность АВП Однако большинство АВП, хотя и не все, оказались эффективными при депрессии со смешанными чертами и/или при депрессии [142]. Большинство исследований, изучавших эффективность АВП
(атипичных антипсихотических средств) при лечении депрессии со смешанными чертами, проводились у пациентов с БАР, поэтому их результаты должны экстраполироваться на РДР с определенной осторожностью. Имеется также опыт эффективного использования АПП и производных бензодиазепина при лечении депрессии со смешанными чертами. Однако АПП имеют много тяжелых побочных эффектов, а производные бензодиазепина - риск развития зависимости [29]. (атипичных антипсихотических средств) при лечении депрессии со смешанными чертами, проводились ⟦стр. 15⟧ у пациентов с БАР, поэтому их результаты должны экстраполироваться на РДР с определенной осторожностью. Имеется также опыт эффективного использования АПП и производных бензодиазепина при лечении депрессии со смешанными чертами. Однако АПП имеют много тяжелых побочных эффектов, а производные бензодиазепина - риск развития зависимости [29].
--- стр. 15 --- --- стр. 15 ---
@@ -437,7 +437,7 @@ F33.9 Рекуррентное депрессивное расстройство
3.2 Купирующая терапия ДЭ на втором этапе 3.2 Купирующая терапия ДЭ на втором этапе
Минимум в 30-50 % случаев ДЭ у пациентов не достигается достаточная эффективность любого из выбранных АД при адекватно проведенной терапии первой линии. В такой ситуации необходимо еще раз проверить, правильно ли был поставлен диагноз, достаточна ли доза назначенного препарата и степень комплаентности пациента. Минимум в 30-50 % случаев ДЭ у пациентов не достигается достаточная эффективность любого из выбранных АД при адекватно проведенной терапии первой линии. В такой ситуации необходимо еще раз проверить, правильно ли был поставлен диагноз, достаточна ли доза назначенного препарата и степень комплаентности пациента.
В случае отсутствия динамики или недостаточного клинического эффекта (степень редукции симптоматики от 25 % до 50 %) в течение 3-4 недель терапии, либо плохой переносимости антидепрессивной фармакотерапии следует переходить ко второму курсу или этапу терапии. В случае отсутствия динамики или недостаточного клинического эффекта (степень редукции симптоматики от 25 % до 50 %) в течение 3-4 недель терапии, либо плохой переносимости антидепрессивной фармакотерапии следует переходить ко второму курсу или этапу терапии.
Доказательства обоснования приведенных стратегий весьма ограничены, поскольку РКИ на больших выборках больных не проводилось. В настоящее время нет четкого консенсуса о предпочтительности какой-либо из стратегий при лечении пациентов, не отвечающих на терапию. Особенно это касается препаратов второго выбора. При анализе данных проекта STAR*D было показано, что процент пациентов с ремиссиями можно увеличить с 27 %, достигнутых при использовании терапии первого выбора, до кумулятивного уровня 67 % - после всех четырех последующих шагов терапевтически вмешательств. Однако вероятность достижения ремиссии после первых двух курсов терапии была выше Доказательства обоснования приведенных стратегий весьма ограничены, поскольку РКИ на больших выборках больных не проводилось. В настоящее время нет четкого консенсуса о предпочтительности какой-либо из стратегий при лечении пациентов, не отвечающих на терапию. Особенно это касается препаратов второго выбора. При анализе данных проекта STAR*D было показано, что процент ⟦стр. 16⟧ пациентов с ремиссиями можно увеличить с 27 %, достигнутых при использовании терапии первого выбора, до кумулятивного уровня 67 % - после всех четырех последующих шагов терапевтически вмешательств. Однако вероятность достижения ремиссии после первых двух курсов терапии была выше
--- стр. 16 --- --- стр. 16 ---
@@ -487,7 +487,7 @@ F33.9 Рекуррентное депрессивное расстройство
100, 150, 300 мг/сут. Максимальная доза не должна превышать 600 мг/сут [192, 193]. При этом необходимо учитывать возможность появления седации и набора веса. При применении оланзапина** в целях аугментации эффекта флуоксетина** рекомендуемый диапазон доз составляет 5-10 мг/сут 100, 150, 300 мг/сут. Максимальная доза не должна превышать 600 мг/сут [192, 193]. При этом необходимо учитывать возможность появления седации и набора веса. При применении оланзапина** в целях аугментации эффекта флуоксетина** рекомендуемый диапазон доз составляет 5-10 мг/сут
(доза 5 мг вне указанного режима применения препарата в инструкции по медицинскому применению оланзапина**) [211]. Наиболее часто возникают такие побочные эффекты, как увеличение веса, седация и повышение пролактина. При применении #карипразина** диапазон доз варьируется от 1,5 до (доза 5 мг вне указанного режима применения препарата в инструкции по медицинскому применению оланзапина**) [211]. Наиболее часто возникают такие побочные эффекты, как увеличение веса, седация и повышение пролактина. При применении #карипразина** диапазон доз варьируется от 1,5 до
3 мг/сут; стартовая доза - 1,5 мг/сут [199, 200, 201, 202, 391]. Наиболее частыми нежелательными явлениями при применении #карипразина** являются экстрапирамидные расстройства, акатизия. Что касается применения #рисперидона** при аугментации антидепрессивной терапии, рекомендуемой стартовой дозой является 0,5 мг/сут, а диапазон доз составляет 0,25-3 мг/сут [197]. Следует учитывать возможность развития гиперпролактинемии и экстрапирамидных расстройств. 3 мг/сут; стартовая доза - 1,5 мг/сут [199, 200, 201, 202, 391]. Наиболее частыми нежелательными явлениями при применении #карипразина** являются экстрапирамидные расстройства, акатизия. Что касается применения #рисперидона** при аугментации антидепрессивной терапии, рекомендуемой стартовой дозой является 0,5 мг/сут, а диапазон доз составляет 0,25-3 мг/сут [197]. Следует учитывать возможность развития гиперпролактинемии и экстрапирамидных расстройств.
Метаанализ 9 РКИ подтвердил эффективность #брекспипразола при резистентной депрессии при его назначении в начальной дозе 0,5-1 мг с возможным повышением до 3 мг (рекомендуемая средняя доза - 2 мг) [203]. Побочными эффектами #брекспипразола являются акатизия, инсомния, беспокойство, сонливость и увеличение веса. В нескольких РКИ было показано достоверное преимущество эффективности аугментации #зипрасидоном по сравнению с плацебо [204, 205]. Рекомендуемой начальной дозой является 20 мг с возможным повышением до 40-160 мг/сут. Среди побочных эффектов чаще всего возникают акатизия, увеличение веса и увеличение интервала QTc [212]. Данные об аугментирующей эффективности #амисульприда ограничены открытыми исследованиями, в которых препарат добавлялся к базовой антидепрессивной терапии в начальной дозе 50 мг с возможным еженедельным повышением на 25 мг до максимальной дозы 200 мг/сут [206], либо в фиксированной дозе 50 мг/сут [207]. Наиболее частым побочным эффектом при использовании Метаанализ 9 РКИ подтвердил эффективность #брекспипразола при резистентной депрессии при его назначении в начальной дозе 0,5-1 мг с возможным повышением до 3 мг (рекомендуемая средняя доза - 2 мг) [203]. Побочными эффектами #брекспипразола являются акатизия, инсомния, беспокойство, сонливость и увеличение веса. В нескольких РКИ было показано достоверное преимущество эффективности аугментации #зипрасидоном по сравнению с плацебо [204, 205]. Рекомендуемой начальной дозой является 20 мг с возможным повышением до 40-160 мг/сут. Среди побочных ⟦стр. 18⟧ эффектов чаще всего возникают акатизия, увеличение веса и увеличение интервала QTc [212]. Данные об аугментирующей эффективности #амисульприда ограничены открытыми исследованиями, в которых препарат добавлялся к базовой антидепрессивной терапии в начальной дозе 50 мг с возможным еженедельным повышением на 25 мг до максимальной дозы 200 мг/сут [206], либо в фиксированной дозе 50 мг/сут [207]. Наиболее частым побочным эффектом при использовании
--- стр. 18 --- --- стр. 18 ---
@@ -529,7 +529,7 @@ L-триптофаном (биологически активная добавк
СИОЗС с анксиолитиком азаспиродекандиона производным - буспироном (частичный агонист рецепторов СИОЗС с анксиолитиком азаспиродекандиона производным - буспироном (частичный агонист рецепторов
5-НТ1А) [230, 231, 232]. 5-НТ1А) [230, 231, 232].
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2). Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2).
● Пациентам с ДЭ с терапевтической резистентностью, у которых терапия первого, второго и третьего этапов оказалась неэффективной, с целью преодоления резистентности рекомендована транскраниальная магнитная стимуляция (ТМС) либо в качестве монотерапии, либо для аугментации эффекта АД [233, 234, 235, 236]. ● Пациентам с ДЭ с терапевтической резистентностью, у которых терапия первого, второго и третьего этапов оказалась неэффективной, с целью преодоления резистентности рекомендована транскраниальная магнитная стимуляция (ТМС) либо в ⟦стр. 20⟧ качестве монотерапии, либо для аугментации эффекта АД [233, 234, 235, 236].
--- стр. 20 --- --- стр. 20 ---
@@ -573,7 +573,7 @@ L-триптофаном (биологически активная добавк
#карбамазепином** в дозе 200-600 мг/сут [282, 283, 284]. #карбамазепином** в дозе 200-600 мг/сут [282, 283, 284].
Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4). Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4).
Комментарии. Психофармакотерапия была и остается до настоящего времени единственным эффективным методом предотвращения рецидивов РДР. До введения в клиническую практику АД нового поколения для предотвращения рецидивов РДР использовались препараты нормотимического действия. Несмотря на эффективность противорецидивного действия солей лития при РДР в нескольких РКИ [285, 286], в настоящее время более изученными и признанными для профилактики РДР препаратами являются АД. #Карбамазепин** рекомендуется использовать при непереносимости АД и солей лития [17]. Комментарии. Психофармакотерапия была и остается до настоящего времени единственным эффективным методом предотвращения рецидивов РДР. До введения в клиническую практику АД нового поколения для предотвращения рецидивов РДР использовались препараты нормотимического действия. Несмотря на эффективность противорецидивного действия солей лития при РДР в нескольких РКИ [285, 286], в настоящее время более изученными и признанными для профилактики РДР препаратами являются АД. #Карбамазепин** рекомендуется использовать при непереносимости АД и солей лития [17].
Уже первые исследования противорецидивной эффективности ТЦА [256, 286] при РДР показали их преимущество перед плацебо. Способность достоверно уменьшать риск рецидива депрессии выявлена также у препарата тетрациклической структуры (с различным механизмом действия) - миансерина [287]. Показано, что снижение дозы уменьшает эффективность профилактической терапии [256, 259]. В то же время, препятствием для длительного использования гетероциклических АД является почти неизбежное развитие у больных побочных эффектов, ограничивающее возможность применения адекватных доз препаратов. ТЦА не рекомендуются для профилактики РДР из-за кардиотоксичности и способности накапливаться в сердечной мышце при длительном приеме. Длительная терапия ТЦА допустима лишь в случае неэффективности других АД и только при условии регулярного контроля переносимости и ЭКГ. В отличие от ТЦА АД нового поколения обладают хорошей переносимостью во всем диапазоне дозировок, что является важным их преимуществом при проведении длительной терапии. Уже первые исследования противорецидивной эффективности ТЦА [256, 286] при РДР показали их преимущество перед плацебо. Способность достоверно уменьшать риск рецидива депрессии выявлена также у препарата тетрациклической структуры (с различным механизмом действия) - миансерина [287]. Показано, что снижение дозы уменьшает эффективность ⟦стр. 22⟧ профилактической терапии [256, 259]. В то же время, препятствием для длительного использования гетероциклических АД является почти неизбежное развитие у больных побочных эффектов, ограничивающее возможность применения адекватных доз препаратов. ТЦА не рекомендуются для профилактики РДР из-за кардиотоксичности и способности накапливаться в сердечной мышце при длительном приеме. Длительная терапия ТЦА допустима лишь в случае неэффективности других АД и только при условии регулярного контроля переносимости и ЭКГ. В отличие от ТЦА АД нового поколения обладают хорошей переносимостью во всем диапазоне дозировок, что является важным их преимуществом при проведении длительной терапии.
--- стр. 22 --- --- стр. 22 ---
@@ -610,7 +610,7 @@ L-триптофаном (биологически активная добавк
307, 308, 311, 312, 313, 314, 315, 316]. 307, 308, 311, 312, 313, 314, 315, 316].
Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1). Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1).
Комментарии. Комбинация одного из валидизированных методов психотерапии и фармакотерапии может применяться в качестве инициального лечения пациентов с легкой, умеренной и тяжелой депрессией и имеет ряд преимуществ по сравнению с использованием монотерапии антидепрессантами: улучшение показателей краткосрочного восстановления пациентов, более быстрый терапевтический ответ, снижение частоты рецидивов и выраженности резидуальной депрессивной симптоматики, улучшение долгосрочного социального функционирования, повышение комплаенса, более высокая удовлетворенность пациентов и снижение долгосрочных затрат на здравоохранение и социальные услуги [294, 312, 317, 318]. Комментарии. Комбинация одного из валидизированных методов психотерапии и фармакотерапии может применяться в качестве инициального лечения пациентов с легкой, умеренной и тяжелой депрессией и имеет ряд преимуществ по сравнению с использованием монотерапии антидепрессантами: улучшение показателей краткосрочного восстановления пациентов, более быстрый терапевтический ответ, снижение частоты рецидивов и выраженности резидуальной депрессивной симптоматики, улучшение долгосрочного социального функционирования, повышение комплаенса, более высокая удовлетворенность пациентов и снижение долгосрочных затрат на здравоохранение и социальные услуги [294, 312, 317, 318].
Проведение психотерапии у больных с депрессией является особенно целесообразным при наличии значимых психосоциальных стрессоров, межличностных и семейных конфликтов, сопутствующих личностных нарушений, предпочтений пациента, доступности квалифицированных специалистов, сведений о положительном ответе на определенную психотерапевтическую интервенцию в прошлом [295]. Сравнительное сопоставление различных методов психотерапии затруднительно в связи со значительным многообразием их разновидностей, вариабельностью получаемых результатов, а также прослеживающейся в ряде случаев пристрастностью авторов публикаций [319, 320]. Вместе с тем, по мнению некоторых авторов, ведущую роль в достижении терапевтического эффекта играют не столько специфические особенности метода, сколько неспецифические психологические факторы (например, качество контакта между пациентом и терапевтом, а также ожидания от эффектов терапии со стороны пациента и специалиста, осуществляющего терапию) [321]. Проведение психотерапии у больных с депрессией является особенно целесообразным при наличии значимых психосоциальных стрессоров, межличностных и семейных конфликтов, сопутствующих личностных нарушений, предпочтений пациента, доступности квалифицированных специалистов, сведений о положительном ответе на определенную психотерапевтическую интервенцию в прошлом [295]. Сравнительное сопоставление различных методов психотерапии затруднительно в связи со значительным многообразием их разновидностей, вариабельностью получаемых результатов, а также прослеживающейся в ряде случаев пристрастностью авторов публикаций [319, 320]. Вместе с тем, по мнению некоторых авторов, ведущую роль в достижении терапевтического эффекта играют не столько специфические особенности метода, сколько неспецифические психологические факторы (например, качество контакта между пациентом и ⟦стр. 24⟧ терапевтом, а также ожидания от эффектов терапии со стороны пациента и специалиста, осуществляющего терапию) [321].
--- стр. 24 --- --- стр. 24 ---
@@ -627,7 +627,7 @@ L-триптофаном (биологически активная добавк
Комментарии. Психодинамическая психотерапия нацелена на углубленное самопознание пациентом своей психики и причин своего поведения, что приводит к самоосвобождению от симптомов и страданий, связанных с депрессией. Возникающие в процессе терапии инсайты нередко сопровождаются корректирующими переживаниями, которые могут оказывать конструктивное влияние на поведение и стиль реагирования пациента. В центре внимания терапии обычно находятся отношения между пациентом и терапевтом, а также преодоление незрелых защит "Я", искажающих восприятие и переработку информации при депрессии. Основными приемами психодинамической психотерапии являются конфронтация, прояснение, интерпретация и проработка. Их суть заключается в повторяющемся обращении клиента к переживаниям, связанным с прошлым или текущими психологическими проблемами, их переоценке, в привитии навыка аналитического отношения к себе и жизни. Все это способствует развитию у пациента способности вступать в доверительные отношения, формированию более зрелых защит и расширению спектра переживаемых эмоций. Комментарии. Психодинамическая психотерапия нацелена на углубленное самопознание пациентом своей психики и причин своего поведения, что приводит к самоосвобождению от симптомов и страданий, связанных с депрессией. Возникающие в процессе терапии инсайты нередко сопровождаются корректирующими переживаниями, которые могут оказывать конструктивное влияние на поведение и стиль реагирования пациента. В центре внимания терапии обычно находятся отношения между пациентом и терапевтом, а также преодоление незрелых защит "Я", искажающих восприятие и переработку информации при депрессии. Основными приемами психодинамической психотерапии являются конфронтация, прояснение, интерпретация и проработка. Их суть заключается в повторяющемся обращении клиента к переживаниям, связанным с прошлым или текущими психологическими проблемами, их переоценке, в привитии навыка аналитического отношения к себе и жизни. Все это способствует развитию у пациента способности вступать в доверительные отношения, формированию более зрелых защит и расширению спектра переживаемых эмоций.
● Пациентам с ДЭ или РДР с целью с целью повышения эффективности лечения рекомендуется проведение ТОО путем разъяснения больным роли негативных моделей мышления в возникновении депрессии и формировании у них осознанности на основе медитативных практик [298, 313, 335, 336, 337, 338, 339, 340, 341]. ● Пациентам с ДЭ или РДР с целью с целью повышения эффективности лечения рекомендуется проведение ТОО путем разъяснения больным роли негативных моделей мышления в возникновении депрессии и формировании у них осознанности на основе медитативных практик [298, 313, 335, 336, 337, 338, 339, 340, 341].
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1). Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1).
Комментарии. По существу ТОО базируется на активном применении медитативных практик, с целью взглянуть на себя и свои психические процессы как бы со стороны, перейдя в метакогнитивную позицию. Указанная позиция позволяет пациенту "растождествиться" с дезадаптивными мыслями, чувствами и реакциями. Этот метод может использоваться более широко, поскольку является более доступным многим пациентам с депрессией, которые испытывают сложности при отслеживании и оспаривании имеющихся у них дисфункциональных когниций. Комментарии. По существу ТОО базируется на активном применении медитативных практик, с целью взглянуть на себя и свои психические процессы как бы со стороны, перейдя в метакогнитивную позицию. Указанная позиция позволяет пациенту "растождествиться" с дезадаптивными мыслями, чувствами и реакциями. Этот метод может использоваться более широко, ⟦стр. 25⟧ поскольку является более доступным многим пациентам с депрессией, которые испытывают сложности при отслеживании и оспаривании имеющихся у них дисфункциональных когниций.
--- стр. 25 --- --- стр. 25 ---
@@ -652,7 +652,7 @@ L-триптофаном (биологически активная добавк
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1). Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1).
Комментарий: Применение светотерапии целесообразно прежде всего при сезонном аффективном расстройстве - подтипе РДР, при котором выявляется зависимость депрессивных эпизодов от времени года, обычно возникающих в осенне-зимний период. Комментарий: Применение светотерапии целесообразно прежде всего при сезонном аффективном расстройстве - подтипе РДР, при котором выявляется зависимость депрессивных эпизодов от времени года, обычно возникающих в осенне-зимний период.
- Депривация сна [356, 357]; - Депривация сна [356, 357];
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2). Уровень убедительности рекомендаций B (уровень достоверности ⟦стр. 26⟧ доказательств - 2).
--- стр. 26 --- --- стр. 26 ---
@@ -687,7 +687,7 @@ L-триптофаном (биологически активная добавк
● Всем пациентам с ДЭ или РДР рекомендуется проведение различных методов психотерапии (см. Подраздел 3.7. Иное лечение. Психотерапия) с целью поддержания ремиссии, снижения интенсивности рецидивирования и улучшения социального функционирования больных на профилактическом этапе лечения [294, 298, 303, 313, 314, ● Всем пациентам с ДЭ или РДР рекомендуется проведение различных методов психотерапии (см. Подраздел 3.7. Иное лечение. Психотерапия) с целью поддержания ремиссии, снижения интенсивности рецидивирования и улучшения социального функционирования больных на профилактическом этапе лечения [294, 298, 303, 313, 314,
334, 337]. 334, 337].
Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1). Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1).
● Всем пациентам, а также родственникам пациентов с целью повышения осведомленности о природе заболевания, его течении, прогнозе и методах лечения рекомендуется применение групповой психообразовательной работы с больными с психическими расстройствами и расстройствами поведения и групповой психообразовательной работы с родственниками больного с психическими расстройствами и расстройствами поведения [240, 372, 373]. ● Всем пациентам, а также родственникам пациентов с целью повышения осведомленности о природе заболевания, его течении, прогнозе и методах лечения рекомендуется применение групповой психообразовательной работы с больными с психическими расстройствами и расстройствами поведения и групповой психообразовательной работы с родственниками больного с психическими ⟦стр. 27⟧ расстройствами и расстройствами поведения [240, 372, 373].
--- стр. 27 --- --- стр. 27 ---
@@ -783,7 +783,7 @@ J Affect Disord. 2013; 148(1): 28-36.
18. Maurer D.M., Raymond T.J., Davis B.N. Depression: screening and diagnosis. Am Fam Physician. 18. Maurer D.M., Raymond T.J., Davis B.N. Depression: screening and diagnosis. Am Fam Physician.
2018; 98(8):508-515. 2018; 98(8):508-515.
19. Цукарзи Э.Э. Суицид: оценка рисков и первая помощь//Современная терапия психических расстройств. - 2011. - N 2. - С. 30-39. 19. Цукарзи Э.Э. Суицид: оценка рисков и первая помощь//Современная терапия психических расстройств. - 2011. - N 2. - С. 30-39.
20. Benazzi F., Akiskal H.S. Psychometric delineation of the most discriminant symptoms of depressive mixed states. Psychiatry Res. 2006; 141(1): 81-88. 20. Benazzi F., Akiskal H.S. Psychometric delineation of the most discriminant symptoms of depressive ⟦стр. 30⟧ mixed states. Psychiatry Res. 2006; 141(1): 81-88.
--- стр. 30 --- --- стр. 30 ---
@@ -822,7 +822,7 @@ JM, Danchin N, Limosin F, Lemogne C. Association between depression and anemia i
27238642. 27238642.
42. Steffen A., Nubel J., Jacobi F., Batzing J., Holstiege J. Mental and somatic comorbidity of depression: a comprehensive cross-sectional analysis of 202 diagnosis groups using German nationwide ambulatory claims data. BMC Psychiatry. 2020; 20(1):142. 42. Steffen A., Nubel J., Jacobi F., Batzing J., Holstiege J. Mental and somatic comorbidity of depression: a comprehensive cross-sectional analysis of 202 diagnosis groups using German nationwide ambulatory claims data. BMC Psychiatry. 2020; 20(1):142.
43. Penninx B.W., Milaneschi Y., Lamers F., Vogelzangs N. Understanding the somatic consequences of depression: biological mechanisms and the role of depression symptom profile. BMC Med. 2013; 11:129. 43. Penninx B.W., Milaneschi Y., Lamers F., Vogelzangs N. Understanding the somatic consequences of depression: biological mechanisms and the role of depression symptom profile. BMC Med. 2013; 11:129.
44. Maier W., Falkai P. The epidemiology of comorbidity between depression, anxiety disorders and somatic diseases. Int Clin Psychopharmacol. 1999 May; 14 Suppl 2:S1-6. 44. Maier W., Falkai P. The epidemiology of comorbidity between depression, anxiety disorders and ⟦стр. 31⟧ somatic diseases. Int Clin Psychopharmacol. 1999 May; 14 Suppl 2:S1-6.
--- стр. 31 --- --- стр. 31 ---
@@ -864,7 +864,7 @@ T.G., Divall P., Subramaniam H., Mukaetova-Ladinska E., Beishon L. Investigating
299:144-158. doi: 10.1016/j.jad.2021.11.037. Epub 2021 Nov 18. PMID: 34800572. 299:144-158. doi: 10.1016/j.jad.2021.11.037. Epub 2021 Nov 18. PMID: 34800572.
68. Wang Y., Zhang H., Tang S. et al. Assessing regional cerebral blood flow in depression using 68. Wang Y., Zhang H., Tang S. et al. Assessing regional cerebral blood flow in depression using
320-slice computed tomography. PLoS One. 2014; 9(9):e107735. 320-slice computed tomography. PLoS One. 2014; 9(9):e107735.
69. Jiang X., Wang X., Jia L. et al. Structural and functional alterations in untreated patients with major depressive disorder and bipolar disorder experiencing first depressive episode: A magnetic resonance imaging study combined with follow-up. J Affect Disord. 202; 279:324-333. 69. Jiang X., Wang X., Jia L. et al. Structural and functional alterations in untreated patients with major ⟦стр. 32⟧ depressive disorder and bipolar disorder experiencing first depressive episode: A magnetic resonance imaging study combined with follow-up. J Affect Disord. 202; 279:324-333.
--- стр. 32 --- --- стр. 32 ---
@@ -901,7 +901,7 @@ Syst Rev. 2017 Jul 5; 7(7):CD011520.
92. Linde K., Berner M.M., Kriston L. St John's wort for major depression. Cochrane Database Syst 92. Linde K., Berner M.M., Kriston L. St John's wort for major depression. Cochrane Database Syst
Rev 2008; (4):CD000448. Rev 2008; (4):CD000448.
93. Kasper S., Caraci F., Forti B. et al. Efficacy and tolerability of Hypericum extract for the treatment of mild to moderate depression. Eur Neuropsychopharmacol. 2010 Nov; 20(11):747-65. 93. Kasper S., Caraci F., Forti B. et al. Efficacy and tolerability of Hypericum extract for the treatment of mild to moderate depression. Eur Neuropsychopharmacol. 2010 Nov; 20(11):747-65.
94. Bauer M., Tharmanathan P., Volz H.P. et al. The effect of venlafaxine compared with other antidepressants and placebo in the treatment of major depression: a meta-analysis. Eur Arch Psychiatry Clin 94. Bauer M., Tharmanathan P., Volz H.P. et al. The effect of venlafaxine compared with other ⟦стр. 33⟧ antidepressants and placebo in the treatment of major depression: a meta-analysis. Eur Arch Psychiatry Clin
--- стр. 33 --- --- стр. 33 ---
@@ -983,7 +983,7 @@ Psychopharmacol. 2015. 29(5): 459-525.
Evidence-based guidelines for treating bipolar disorder: revised second edition. Recommendations from the Evidence-based guidelines for treating bipolar disorder: revised second edition. Recommendations from the
British Association for Psychopharmacology. J Psychopharmacol. 2009; 23(4): 346-388. British Association for Psychopharmacology. J Psychopharmacol. 2009; 23(4): 346-388.
138. Grunze H, Vieta E, Goodwin GM, et al. The World Federation of Societies of Biological 138. Grunze H, Vieta E, Goodwin GM, et al. The World Federation of Societies of Biological
Psychiatry (WFSBP) Guidelines for the Biological Treatment of Bipolar Disorders: update 2010 on the treatment of acute bipolar depression. World J Biol Psychiatry. 2010; 11(2): 81-109. Psychiatry (WFSBP) Guidelines for the Biological Treatment of Bipolar Disorders: update 2010 on the ⟦стр. 35⟧ treatment of acute bipolar depression. World J Biol Psychiatry. 2010; 11(2): 81-109.
--- стр. 35 --- --- стр. 35 ---
@@ -1067,7 +1067,7 @@ Episodes with Mixed Features: A Systematic Review. Clin Psychopharmacol Neurosci
Carvalho AF. Mixed states in bipolar and major depressive disorders: systematic review and quality appraisal of guidelines. Acta Psychiatr Scand. 2018 Sep; 138(3):196-222. Carvalho AF. Mixed states in bipolar and major depressive disorders: systematic review and quality appraisal of guidelines. Acta Psychiatr Scand. 2018 Sep; 138(3):196-222.
181. Houston JP, Tohen M, Degenhardt EK, Jamal HH, Liu LL, Ketter TA. Olanzapine-divalproex combination versus divalproex monotherapy in the treatment of bipolar mixed episodes: a double-blind, placebo-controlled study. J Clin Psychiatry. 2009 Nov; 70(11):1540-7. 181. Houston JP, Tohen M, Degenhardt EK, Jamal HH, Liu LL, Ketter TA. Olanzapine-divalproex combination versus divalproex monotherapy in the treatment of bipolar mixed episodes: a double-blind, placebo-controlled study. J Clin Psychiatry. 2009 Nov; 70(11):1540-7.
182. Rocha FL, Fuzikawa C, Riera R, Hara C. Combination of antidepressants in the treatment of major depressive disorder: a systematic review and meta-analysis. J Clin Psychopharmacol. 2012 Apr; 32(2):278-81. 182. Rocha FL, Fuzikawa C, Riera R, Hara C. Combination of antidepressants in the treatment of major depressive disorder: a systematic review and meta-analysis. J Clin Psychopharmacol. 2012 Apr; 32(2):278-81.
183. Carpenter L.L., Yasmin S., Price L.H. A double-blind, placebo-controlled study of antidepressant augmentation with mirtazapine. Biol Psychiatry 2002; 51 (2): 183-188. 183. Carpenter L.L., Yasmin S., Price L.H. A double-blind, placebo-controlled study of antidepressant ⟦стр. 37⟧ augmentation with mirtazapine. Biol Psychiatry 2002; 51 (2): 183-188.
--- стр. 37 --- --- стр. 37 ---
@@ -1144,7 +1144,7 @@ Psychiatry. 1998; 59 Suppl 5:51-8/.
227. UK ECT Review Group. Efficacy and safety of electroconvulsive therapy in depressive disorders: a systematic review and meta-analysis. Lancet. 2003; 361(9360):799-808. 227. UK ECT Review Group. Efficacy and safety of electroconvulsive therapy in depressive disorders: a systematic review and meta-analysis. Lancet. 2003; 361(9360):799-808.
228. Sackeim H.A., Prudic J., Devanand D.P. et al. A prospective, randomized, double-blind comparison of bilateral and right unilateral electroconvulsive therapy at different stimulus intensities. Arch Gen 228. Sackeim H.A., Prudic J., Devanand D.P. et al. A prospective, randomized, double-blind comparison of bilateral and right unilateral electroconvulsive therapy at different stimulus intensities. Arch Gen
Psychiatry 2000; 57 (5): 425-434. Psychiatry 2000; 57 (5): 425-434.
229. Прохорова И.С. Некоторые теоретические и практические аспекты применения одномоментной отмены психотропных средств. Автореферат дисс канд. мед. наук. М., 1975; 22 с. 229. Прохорова И.С. Некоторые теоретические и практические аспекты применения ⟦стр. 39⟧ одномоментной отмены психотропных средств. Автореферат дисс канд. мед. наук. М., 1975; 22 с.
--- стр. 39 --- --- стр. 39 ---
@@ -1237,7 +1237,7 @@ Assessment, Impact, and Management. Focus (Am Psychiatr Publ); 14(2):194-206. do
2021; 40 с. 2021; 40 с.
272. Ахапкин Р.В. Когнитивные нарушения при депрессивных расстройствах: анализ проблемы и перспективы решения (обзор литературы)//Современная Терапия Психических Расстройств. 2015. Т. 272. Ахапкин Р.В. Когнитивные нарушения при депрессивных расстройствах: анализ проблемы и перспективы решения (обзор литературы)//Современная Терапия Психических Расстройств. 2015. Т.
N 4. СС. 2-7. N 4. СС. 2-7.
273. McIntyre RS, Xiao HX, Syeda K. et al. The prevalence, measurement, and treatment of the cognitive dimension/domain in major depressive disorder. CNS Drugs. 2015; 29(7):577-89. 273. McIntyre RS, Xiao HX, Syeda K. et al. The prevalence, measurement, and treatment of the ⟦стр. 41⟧ cognitive dimension/domain in major depressive disorder. CNS Drugs. 2015; 29(7):577-89.
--- стр. 41 --- --- стр. 41 ---
@@ -1325,7 +1325,7 @@ Driel ML. The effects of social group interventions for depression: Systematic r
49(6):361-72. 49(6):361-72.
319. Kaiser T. et al. Heterogeneity of Treatment Effects in Trials on Psychotherapy of Depression//Clin. 319. Kaiser T. et al. Heterogeneity of Treatment Effects in Trials on Psychotherapy of Depression//Clin.
Psychol. Sci. Pract. 2022. Vol. 29, N 3. Psychol. Sci. Pract. 2022. Vol. 29, N 3.
320. Cristea I.A. et al. Sponsorship bias in the comparative efficacy of psychotherapy and pharmacotherapy for adult depression: Meta-analysis//British Journal of Psychiatry. 2017. Vol. 210, N 1. 320. Cristea I.A. et al. Sponsorship bias in the comparative efficacy of psychotherapy and ⟦стр. 43⟧ pharmacotherapy for adult depression: Meta-analysis//British Journal of Psychiatry. 2017. Vol. 210, N 1.
--- стр. 43 --- --- стр. 43 ---
@@ -1421,7 +1421,7 @@ B.C.
Г.П., Г.П.,
Цуцульковская М.Я. Аффективные и шизоаффективные психозы: Материалы науч.-практ. конф. с междунар. участием. 7-8 апреля 1998 г.; с. 386-390. Цуцульковская М.Я. Аффективные и шизоаффективные психозы: Материалы науч.-практ. конф. с междунар. участием. 7-8 апреля 1998 г.; с. 386-390.
367. Кекелидзе З.И., Тваладзе М.Г., Чечелашвили А.П. и соавт. Роль и место инфузий облученной ультрафиолетом аутокрови в лечении депрессивных состояний. Социальная и клиническая психиатрия 1993; N 3: с. 88-94. 367. Кекелидзе З.И., Тваладзе М.Г., Чечелашвили А.П. и соавт. Роль и место инфузий облученной ультрафиолетом аутокрови в лечении депрессивных состояний. Социальная и клиническая психиатрия 1993; N 3: с. 88-94.
368. Каримулаев И.А., Калинин В.В., Мосолов С.Н. Применение метода адаптации к периодической нормобарической гипоксии в лечении фармакорезистентных депрессий. В книге: Новые достижения в терапии психических заболеваний./ред. С.Н. Мосолов/- М., 2002; c. 579-592. 368. Каримулаев И.А., Калинин В.В., Мосолов С.Н. Применение метода адаптации к периодической нормобарической гипоксии в лечении фармакорезистентных депрессий. В книге: Новые ⟦стр. 45⟧ достижения в терапии психических заболеваний./ред. С.Н. Мосолов/- М., 2002; c. 579-592.
--- стр. 45 --- --- стр. 45 ---
@@ -1525,7 +1525,7 @@ Family Physician. - 2006. - Vol. 74 (3). - Pp. 449-456.
Минюсте России 14.11.2022 N 70940). Минюсте России 14.11.2022 N 70940).
4. Приказ Минздрава России от 30.06.2022 N 453н "Об утверждении Порядка диспансерного наблюдения за лицом, страдающим хроническим и затяжным психическим расстройством с тяжелыми стойкими или часто обостряющимися болезненными проявлениями"(Зарегистрировано в Минюсте 4. Приказ Минздрава России от 30.06.2022 N 453н "Об утверждении Порядка диспансерного наблюдения за лицом, страдающим хроническим и затяжным психическим расстройством с тяжелыми стойкими или часто обостряющимися болезненными проявлениями"(Зарегистрировано в Минюсте
России 28.07.2022 N 69429). России 28.07.2022 N 69429).
5. Приказ Министерства Здравоохранения Российской Федерации от 20.12.2012 N 1219н "Об утверждении стандарта первичной медико-социальной помощи при депрессиях легкой и средней степени тяжести и смешанном тревожном и депрессивном расстройстве в амбулаторных условиях психоневрологического диспансера (диспансерного отделения, кабинета)". 5. Приказ Министерства Здравоохранения Российской Федерации от 20.12.2012 N 1219н "Об утверждении стандарта первичной медико-социальной помощи при депрессиях легкой и средней ⟦стр. 48⟧ степени тяжести и смешанном тревожном и депрессивном расстройстве в амбулаторных условиях психоневрологического диспансера (диспансерного отделения, кабинета)".
--- стр. 48 --- --- стр. 48 ---
@@ -1961,7 +1961,7 @@ Mosolov S.N., Yaltonskaya P.A., Senko O.V., Angst J. Validation of the Russian v
□ □
□ □
2) Как Вы обычно оцениваете себя по сравнению с другими людьми? 2) Как Вы обычно оцениваете себя по сравнению с другими людьми?
Вне зависимости от того, как Вы чувствуете себя сейчас, укажите, как вы обычно воспринимаете себя по другими людьми. Выберите наиболее подходящую формулировку из перечисленных ниже: Вне зависимости от того, как Вы чувствуете себя сейчас, укажите, как вы обычно воспринимаете ⟦стр. 62⟧ себя по другими людьми. Выберите наиболее подходящую формулировку из перечисленных ниже:
--- стр. 62 --- --- стр. 62 ---
+44 -44
View File
@@ -37,7 +37,7 @@ ID: 753
# – в инструкции по применению не указано данное заболевание или расстройство # – в инструкции по применению не указано данное заболевание или расстройство
Термины и определения Термины и определения
Травма – событие, связанное с мощным психотравмирующим воздействием и сопровождающимся стрессом экстремального характера. Травма – событие, связанное с мощным психотравмирующим воздействием и сопровождающимся стрессом экстремального характера.
Стресс – совокупность неспецифических адаптационных (нормальных) реакций организма на воздействие различных неблагоприятных факторов-стрессоров (физических или психологических), нарушающее его гомеостаз, а также соответствующее состояние нервной системы организма (или организма в целом). В медицине, физиологии, психологии выделяют положительную (эустресс от др. греч. Стресс – совокупность неспецифических адаптационных (нормальных) реакций ⟦стр. 2⟧ организма на воздействие различных неблагоприятных факторов-стрессоров (физических или психологических), нарушающее его гомеостаз, а также соответствующее состояние нервной системы организма (или организма в целом). В медицине, физиологии, психологии выделяют положительную (эустресс от др. греч.
--- стр. 2 --- --- стр. 2 ---
@@ -62,7 +62,7 @@ ID: 753
ПТСР развивается вследствие неспособности индивида справиться с экзогенным психотравмирующим воздействием чрезвычайного характера. В настоящее время активно исследуется вклад генетических факторов, в том числе с целью поиска возможных новых препаратов для лечения ПТСР, на сегодняшний день имеется большая база генетических исследований, которые указывают на полигенный характер фенотипа ПТСР, при этом вопрос о соотношении собственно генетических и средовых факторов остается открытым. Хорошо известно, что предшествующая травматизация повышает риск развития ПТСР, но пока нет однозначного описания патогенетических механизмов подобной сенситизации [3, 4, 5, 6]. ПТСР развивается вследствие неспособности индивида справиться с экзогенным психотравмирующим воздействием чрезвычайного характера. В настоящее время активно исследуется вклад генетических факторов, в том числе с целью поиска возможных новых препаратов для лечения ПТСР, на сегодняшний день имеется большая база генетических исследований, которые указывают на полигенный характер фенотипа ПТСР, при этом вопрос о соотношении собственно генетических и средовых факторов остается открытым. Хорошо известно, что предшествующая травматизация повышает риск развития ПТСР, но пока нет однозначного описания патогенетических механизмов подобной сенситизации [3, 4, 5, 6].
Острые стрессовые реакция являются нормальным ответом на чрезвычайное воздействия у ряда людей. Из-за дефицита ресурсов адаптабельности естественный ответ на стресс принимает патологический характер, в частности нарушается функционирование гипоталамо-гипофизарно-надпочечниковой оси и симпатоадреналовой системы, что приводит к манифестации симптомов ПТСР, повышенной готовности к тревожным реакциям, физиологическому возбуждению и гипервигилитету [7, 8, 9, 10, 11, 12, 13]. Острые стрессовые реакция являются нормальным ответом на чрезвычайное воздействия у ряда людей. Из-за дефицита ресурсов адаптабельности естественный ответ на стресс принимает патологический характер, в частности нарушается функционирование гипоталамо-гипофизарно-надпочечниковой оси и симпатоадреналовой системы, что приводит к манифестации симптомов ПТСР, повышенной готовности к тревожным реакциям, физиологическому возбуждению и гипервигилитету [7, 8, 9, 10, 11, 12, 13].
В когнитивных моделях ПТСР, которые в настоящее время нашли свое подтверждение в исследованиях с использованием современных возможностей нейровизуализации, патогенез ПТСР тесно связан с нарушением процессов переработки информации и интеграции травматического опыта в общую биографическую память пациента. Было выявлено, что травматические воспоминания ассоциированы со сниженной активностью левой нижней фронтальной коры, что дает возможность предположить, что в этих воспоминаниях преобладают эмоциональные и сенсорные элементы без соответствующих лингвистических ассоциаций. В когнитивных моделях ПТСР, которые в настоящее время нашли свое подтверждение в исследованиях с использованием современных возможностей нейровизуализации, патогенез ПТСР тесно связан с нарушением процессов переработки информации и интеграции травматического опыта в общую биографическую память пациента. Было выявлено, что травматические воспоминания ассоциированы со сниженной активностью левой нижней фронтальной коры, что дает возможность предположить, что в этих воспоминаниях преобладают эмоциональные и сенсорные элементы без соответствующих лингвистических ассоциаций.
Информация о травме существует в активной рабочей памяти до тех пор, пока не произойдет ее осмысление. Таким образом формируется цикл оживления травматических воспоминаниях под воздействием триггеров, связанных с травмой и стремлением избегать неприятный опыт с попытками контролировать ход мыслей и избегать столкновения со всем, что может напоминать о травме, что в свою очередь ухудшает переработку травматического опыта. В норме эпизоды навязчивых наплывов травматических переживаний чередуются с периодами избегания, со временем по мере завершения процесса эти состояния становятся реже и менее интенсивными. В ряде случаев может возникать конфликт между потребностью в осмыслении и существующей системой отношений, в этом случае возникает конфликт с последующим развитием избегающего поведения, эмоционального онемения, как проявления психологической защиты, что формирует почву для последующей манифестации коморбидных депрессивных расстройств. В этих случаях аффект утрачивает свою сигнальную функцию, поскольку любая сильная эмоция воспринимается как опасность возвращения травматического опыта [17, 18, 19, 20]. Информация о травме существует в активной рабочей памяти до тех пор, пока не ⟦стр. 4⟧ произойдет ее осмысление. Таким образом формируется цикл оживления травматических воспоминаниях под воздействием триггеров, связанных с травмой и стремлением избегать неприятный опыт с попытками контролировать ход мыслей и избегать столкновения со всем, что может напоминать о травме, что в свою очередь ухудшает переработку травматического опыта. В норме эпизоды навязчивых наплывов травматических переживаний чередуются с периодами избегания, со временем по мере завершения процесса эти состояния становятся реже и менее интенсивными. В ряде случаев может возникать конфликт между потребностью в осмыслении и существующей системой отношений, в этом случае возникает конфликт с последующим развитием избегающего поведения, эмоционального онемения, как проявления психологической защиты, что формирует почву для последующей манифестации коморбидных депрессивных расстройств. В этих случаях аффект утрачивает свою сигнальную функцию, поскольку любая сильная эмоция воспринимается как опасность возвращения травматического опыта [17, 18, 19, 20].
--- стр. 4 --- --- стр. 4 ---
@@ -122,7 +122,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
Диагноз включает дополнения к трем типичным кластерам симптомов ПТСР: Диагноз включает дополнения к трем типичным кластерам симптомов ПТСР:
1. Стойкие длительные нарушения в аффективной сфере (повышенная эмоциональная реактивность, отсутствие эмоций, развитие диссоциативных состояний); 1. Стойкие длительные нарушения в аффективной сфере (повышенная эмоциональная реактивность, отсутствие эмоций, развитие диссоциативных состояний);
2. Поведенческие нарушения (вспышки ярости, безрассудное или саморазрушающее поведение); 2. Поведенческие нарушения (вспышки ярости, безрассудное или саморазрушающее поведение);
3. Изменения в сфере представлений о самом себе (стойкие негативные представления о себе, как об униженном, побежденном и ничего не стоящем человеке, которые могут сопровождаться глубокими и всеохватывающими чувствами стыда, вины или несостоятельности); 3. Изменения в сфере представлений о самом себе (стойкие негативные представления о себе, как об униженном, побежденном и ничего не стоящем человеке, которые могут ⟦стр. 7⟧ сопровождаться глубокими и всеохватывающими чувствами стыда, вины или несостоятельности);
--- стр. 7 --- --- стр. 7 ---
@@ -142,7 +142,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
1. Повторяющиеся переживания травмирующего события (флэшбеки, представленные в навязчивых реминисценциях, ярких навязчивых воспоминаниях или повторяющихся снах). В большинстве случаев они неприятны и аффективно насыщенны [1, 15, 16]. 1. Повторяющиеся переживания травмирующего события (флэшбеки, представленные в навязчивых реминисценциях, ярких навязчивых воспоминаниях или повторяющихся снах). В большинстве случаев они неприятны и аффективно насыщенны [1, 15, 16].
2. Избегание действий и ситуаций, напоминающих о травме (фактическое избегание, чувство "онемения" и эмоционального притупления, отрешенности, невосприимчивости к окружающему, эмоциональная отстраненность, ангедония, ограничительное поведение) [1, 2. Избегание действий и ситуаций, напоминающих о травме (фактическое избегание, чувство "онемения" и эмоционального притупления, отрешенности, невосприимчивости к окружающему, эмоциональная отстраненность, ангедония, ограничительное поведение) [1,
15, 16, 33]. 15, 16, 33].
3. Чрезмерное физиологическое возбуждение (бессонница, связанная с наплывом неприятных воспоминаний о травматическом событии, гипервигилитет сверхнастороженность, повышенная реакция испуга) [1, 14, 15, 16, 33]. 3. Чрезмерное физиологическое возбуждение (бессонница, связанная с наплывом ⟦стр. 8⟧ неприятных воспоминаний о травматическом событии, гипервигилитет сверхнастороженность, повышенная реакция испуга) [1, 14, 15, 16, 33].
--- стр. 8 --- --- стр. 8 ---
@@ -165,7 +165,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
6. Сниженная выраженность аффекта (неспособность, например, к чувству любви). 6. Сниженная выраженность аффекта (неспособность, например, к чувству любви).
7. Чувство отсутствия перспективы в будущем (например, отсутствие ожиданий по поводу карьеры, женитьбы, детей или пожелания долгой жизни). 7. Чувство отсутствия перспективы в будущем (например, отсутствие ожиданий по поводу карьеры, женитьбы, детей или пожелания долгой жизни).
В целом ПТСР вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности. В целом ПТСР вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности.
В динамике развития расстройств на первом этапе ПТСР пациент погружен в мир переживаний, связанных с травмой, отмечаются наиболее выраженные в этот период флэшбеки. На этом этапе нередко отмечается обострение (или диагностируются впервые) хронических соматических заболеваний, особенно тех, в основе которых участвуют психосоматические механизмы формирования (язвенная болезнь двенадцатиперстной кишки и желудка, холецистит, холангит, колит, запоры, бронхиальная астма и др.). Отмечаются также сексологические нарушения: снижение либидо и эрекции. Стоит отметить, что специфические симптомы, проявляющиеся в чувстве беспомощности, растерянности, вторгающихся пугающих образов, стойких руминациях о прогрессировании заболевания, могут в значительной степени влиять на совладание с соматическим заболеванием. В динамике развития расстройств на первом этапе ПТСР пациент погружен в мир ⟦стр. 9⟧ переживаний, связанных с травмой, отмечаются наиболее выраженные в этот период флэшбеки. На этом этапе нередко отмечается обострение (или диагностируются впервые) хронических соматических заболеваний, особенно тех, в основе которых участвуют психосоматические механизмы формирования (язвенная болезнь двенадцатиперстной кишки и желудка, холецистит, холангит, колит, запоры, бронхиальная астма и др.). Отмечаются также сексологические нарушения: снижение либидо и эрекции. Стоит отметить, что специфические симптомы, проявляющиеся в чувстве беспомощности, растерянности, вторгающихся пугающих образов, стойких руминациях о прогрессировании заболевания, могут в значительной степени влиять на совладание с соматическим заболеванием.
--- стр. 9 --- --- стр. 9 ---
@@ -223,7 +223,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
Комментарии: В МКБ-10 выделены следующие критерии ПТСР: Комментарии: В МКБ-10 выделены следующие критерии ПТСР:
А. Больной должен быть подвержен воздействию стрессорного события или ситуации А. Больной должен быть подвержен воздействию стрессорного события или ситуации
(как коротко, так и длительно длящихся) исключительно угрожающего или катастрофического характера, что способно вызвать общий дистресс почти у любого индивидуума. (как коротко, так и длительно длящихся) исключительно угрожающего или катастрофического характера, что способно вызвать общий дистресс почти у любого индивидуума.
Б. стойкие навязчивые воспоминания или "оживление стрессора в навязчивых реминисценциях, ярких воспоминаниях или повторяющихся снах, либо повторные переживания горя при воздействии обстоятельств, напоминающих или ассоциирующихся со стрессором. Б. стойкие навязчивые воспоминания или "оживление стрессора в навязчивых реминисценциях, ярких воспоминаниях или повторяющихся снах, либо повторные ⟦стр. 11⟧ переживания горя при воздействии обстоятельств, напоминающих или ассоциирующихся со стрессором.
--- стр. 11 --- --- стр. 11 ---
@@ -353,7 +353,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Более полно клинико-психологическая и экспериментально-психологическая диагностика может проводиться медицинским психологом в зависимости от конкретных диагностических задач или для определения мишеней психотерапевтического воздействия. Выбор методов и методик психологической диагностики относится к компетенции медицинского психолога, при соблюдении требования о представлении в тексте психологического заключения испрашиваемых лечащим врачом данных. Могут применяться различные шкалы, прошедшие адаптацию и валидизацию, в зависимости от целей исследования. Методики могут применяться факультативно, и перечень возможных методик для проведения экспериментально-психологического обследования не ограничен. При этом одной из важнейших составляющих участия медицинского психолога в оказании медицинской помощи пациентам с ПТСР является психологическая диагностика. Используются все основные виды, методы и методики, принятые как в психологической диагностике в целом, так и в ее прикладном клиническом разделе, именуемом "медицинской психодиагностикой" или "психологической диагностикой в клинике". В соответствии с базовыми теоретическими положениями психологической диагностики, в клинической практике используются и клинико-психологические (экспертные) методы (клиническая беседа, наблюдение, интервью, анализ продуктов деятельности и т.п.), и экспериментально психологические методы. Более полно клинико-психологическая и экспериментально-психологическая диагностика может проводиться медицинским психологом в зависимости от конкретных диагностических задач или для определения мишеней психотерапевтического воздействия. Выбор методов и методик психологической диагностики относится к компетенции медицинского психолога, при соблюдении требования о представлении в тексте психологического заключения испрашиваемых лечащим врачом данных. Могут применяться различные шкалы, прошедшие адаптацию и валидизацию, в зависимости от целей исследования. Методики могут применяться факультативно, и перечень возможных методик для проведения экспериментально-психологического обследования не ограничен. При этом одной из важнейших составляющих участия медицинского психолога в оказании медицинской помощи пациентам с ПТСР является психологическая диагностика. Используются все основные виды, методы и методики, принятые как в психологической диагностике в целом, так и в ее прикладном клиническом разделе, именуемом "медицинской психодиагностикой" или "психологической диагностикой в клинике". В соответствии с базовыми теоретическими положениями психологической диагностики, в клинической практике используются и клинико-психологические (экспертные) методы (клиническая беседа, наблюдение, интервью, анализ продуктов деятельности и т.п.), и экспериментально психологические методы.
Опросниковые методы опираются в значительной степени на самооценочную природу получаемых данных и имеют свои ограничения в части объективности таких данных. С другой стороны, они в большей степени отражают индивидуально-психологические особенности самой личности и ее сохранность/измененность в ситуации болезни, а также раскрывают широкий комплекс присущих испытуемому связей, отношений, способов реагирования и совладания, иных клинически значимых характеристик личности и личностно-средового взаимодействия и др. Такие данные должны расцениваться в рамках персонализированного подхода при реализации биопсихосоциальной парадигмы современных антропоцентрированных и клинических наук. При этом клинические методы органично вплетены в процедуры экспериментально-психологического исследования и используются непосредственно в процессе выполнения испытуемым проб и тестовых методик Отличительной особенностью применения клинико- и экспериментальнопсихологических методов при решении задач психологической диагностики является учет и анализ специфики, обусловленной измененным болезнью психическим и психологическим статусом испытуемого. Опросниковые методы опираются в значительной степени на самооценочную природу получаемых данных и имеют свои ограничения в части объективности таких данных. С другой стороны, они в большей степени отражают индивидуально-психологические особенности самой личности и ее сохранность/измененность в ситуации болезни, а также раскрывают широкий комплекс присущих испытуемому связей, отношений, способов реагирования и совладания, иных клинически значимых характеристик личности и личностно-средового взаимодействия и др. Такие данные должны расцениваться в рамках персонализированного подхода при реализации биопсихосоциальной парадигмы современных антропоцентрированных и клинических наук. При этом клинические методы органично вплетены в процедуры экспериментально-психологического исследования и используются непосредственно в процессе выполнения испытуемым проб и тестовых методик Отличительной особенностью применения клинико- и экспериментальнопсихологических методов при решении задач психологической диагностики является учет и анализ специфики, обусловленной измененным болезнью психическим и психологическим статусом испытуемого.
3. Лечение, включая медикаментозную и немедикаментозную терапии, диетотерапию, обезболивание, медицинские показания и противопоказания к применению методов лечения 3. Лечение, включая медикаментозную и немедикаментозную терапии, диетотерапию, обезболивание, медицинские показания и противопоказания к применению методов лечения
ПТСР – заболевание, которое имеет высокую коморбидностъ с большим количеством психических и соматических расстройств. В лечении пациентов с ПТСР и коморбидными нарушениями целесообразно также опираться на клинические рекомендации и стандарты оказания помощи для этих заболеваний. ПТСР – заболевание, которое имеет высокую коморбидностъ с большим количеством психических и соматических расстройств. В лечении пациентов с ПТСР и коморбидными ⟦стр. 17⟧ нарушениями целесообразно также опираться на клинические рекомендации и стандарты оказания помощи для этих заболеваний.
--- стр. 17 --- --- стр. 17 ---
@@ -383,7 +383,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- диазепам** 5-15 мг/сут (при психомоторном возбуждении, связанном с тревогой, коморбидных острых тревожно-фобических, тревожно-депрессивных состояниях). Дозу обычно делят на 2-3 приема. Длительность курса диазепама** не должна превышать 4 недель, в тяжелых случаях допускается до 8-12 недель, включая время постепенного прекращения [57, 303, 304, 307, 308]. - диазепам** 5-15 мг/сут (при психомоторном возбуждении, связанном с тревогой, коморбидных острых тревожно-фобических, тревожно-депрессивных состояниях). Дозу обычно делят на 2-3 приема. Длительность курса диазепама** не должна превышать 4 недель, в тяжелых случаях допускается до 8-12 недель, включая время постепенного прекращения [57, 303, 304, 307, 308].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
- лоразепам** 2-10 мг/сут (при тревоге, раздражительности, нарушениях сна, вегетативных нарушениях). Обычно назначают 2-3 мг/сут, разделенные на 1-3 приема, а затем при необходимости, увеличивают суточную дозу препарата до поддерживающей, которая чаще всего составляет от 2 мг до 6 мг в сутки, разделенная на 1-3 приема. Для купирования симптомов рекомендуется применять как можно меньшие эффективные дозы. - лоразепам** 2-10 мг/сут (при тревоге, раздражительности, нарушениях сна, вегетативных нарушениях). Обычно назначают 2-3 мг/сут, разделенные на 1-3 приема, а затем при необходимости, увеличивают суточную дозу препарата до поддерживающей, которая чаще всего составляет от 2 мг до 6 мг в сутки, разделенная на 1-3 приема. Для купирования симптомов рекомендуется применять как можно меньшие эффективные дозы.
Дозу лоразепама** необходимо увеличивать постепенно, начиная с увеличения дозы, применяемой вечером. Максимально можно назначить до 10 мг в сутки. При нарушениях сна, вызванных тревогой, обычно назначают раз в сутки перед сном от 2 до 5 мг лоразепама** [57, 303, 309]. Дозу лоразепама** необходимо увеличивать постепенно, начиная с увеличения дозы, применяемой вечером. Максимально можно назначить до 10 мг в сутки. При нарушениях сна, вызванных тревогой, обычно назначают раз в сутки перед сном от 2 до 5 мг ⟦стр. 19⟧ лоразепама** [57, 303, 309].
--- стр. 19 --- --- стр. 19 ---
@@ -413,7 +413,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
(15-80 мг/сут) для купирования этих проявлений [67, 68]. (15-80 мг/сут) для купирования этих проявлений [67, 68].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
Комментарии: курсовое лечение необходимо начинать с приема 2,5-5 мг в вечернее время с постепенным увеличением суточной дозы до требуемого эффекта. Суточная доза может быть распределена на 3-4 приема. Длительность курсового лечения может составлять от 2 до 6 и более месяцев и определяется врачом в зависимости от состояния в соответствии с инструкцией по медицинскому применению препарата. При необходимости для достижения седативного или снотворного эффекта может применяться эпизодически (за 2030 мин до сна). Побочные эффекты крайне редки и выражены незначительно. Препарат может применяться в комбинированной терапии с другими средствами. Комментарии: курсовое лечение необходимо начинать с приема 2,5-5 мг в вечернее время с постепенным увеличением суточной дозы до требуемого эффекта. Суточная доза может быть распределена на 3-4 приема. Длительность курсового лечения может составлять от 2 до 6 и более месяцев и определяется врачом в зависимости от состояния в соответствии с инструкцией по медицинскому применению препарата. При необходимости для достижения седативного или снотворного эффекта может применяться эпизодически (за 2030 мин до сна). Побочные эффекты крайне редки и выражены незначительно. Препарат может применяться в комбинированной терапии с другими средствами.
- Пациентам с ПТСР в качестве анксиолитиков альтернативных производным бензодиазепина рекомендовано назначение этифоксина (150-200 мг/сут) для устранения тревоги, страха, внутреннего напряжения, раздражительности, а также признаков повышенных настороженности и вегетативного возбуждения [69, 70, 71, 72, 72, 74]. - Пациентам с ПТСР в качестве анксиолитиков альтернативных производным бензодиазепина рекомендовано назначение этифоксина (150-200 мг/сут) для устранения тревоги, страха, внутреннего напряжения, раздражительности, а также признаков ⟦стр. 21⟧ повышенных настороженности и вегетативного возбуждения [69, 70, 71, 72, 72, 74].
--- стр. 21 --- --- стр. 21 ---
@@ -427,7 +427,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
(симптоматическое лечение тревоги). Он не угнетает кору головного мозга, но его действие может быть связано с подавлением активности некоторых ключевых зон субкортикальной области центральной нервной системы. В случае применения пероральных лекарственных форм седативный эффект наступает через 30-45 минут. Гидроксизин** также обладает спазмолитическим и симпатолитическим действием, демонстрирует умеренную анальгетическую активность. Рекомендуемая дозировка 25-100 мг/сут. Поскольку реакции на прием гидроксизина** отличаются изменчивостью, рекомендуется начинать лечение с низких доз препарата и постепенно повышать до оптимальной, регулируя ее в соответствии с ответом пациента на терапию. В случае если тревожность проявляется бессонницей, основная доза принимается вечером перед сном. Длительность определяется индивидуально, курс обычно составляет 4-8 недель. Препарат может применяться в комбинированной терапии с другими средствами. (симптоматическое лечение тревоги). Он не угнетает кору головного мозга, но его действие может быть связано с подавлением активности некоторых ключевых зон субкортикальной области центральной нервной системы. В случае применения пероральных лекарственных форм седативный эффект наступает через 30-45 минут. Гидроксизин** также обладает спазмолитическим и симпатолитическим действием, демонстрирует умеренную анальгетическую активность. Рекомендуемая дозировка 25-100 мг/сут. Поскольку реакции на прием гидроксизина** отличаются изменчивостью, рекомендуется начинать лечение с низких доз препарата и постепенно повышать до оптимальной, регулируя ее в соответствии с ответом пациента на терапию. В случае если тревожность проявляется бессонницей, основная доза принимается вечером перед сном. Длительность определяется индивидуально, курс обычно составляет 4-8 недель. Препарат может применяться в комбинированной терапии с другими средствами.
- Пациентам с ПТСР рекомендуется применение анксиолитиков, не относящихся к группе производные бензодиазепина – буспирона (20-30 мг/сут) при наличии коморбидных тревожных расстройств для снижения уровня тревоги [75, 78]. - Пациентам с ПТСР рекомендуется применение анксиолитиков, не относящихся к группе производные бензодиазепина – буспирона (20-30 мг/сут) при наличии коморбидных тревожных расстройств для снижения уровня тревоги [75, 78].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 2). Уровень убедительности рекомендаций C (уровень достоверности доказательств 2).
Комментарии: в отличие от производных бензодиазепина буспирон не обладает миорелаксантными противосудорожными свойствами. Его седативный эффект значительно слабее эффекта типичных анксиолитиков и не нарушает психомоторные функции. По анксиолитической активности буспирон примерно аналогичен эффекту производных бензодиазепина. Буспирон не вызывает толерантности и зависимости, а после завершения курса лечения не развиваются симптомы отмены. Действие буспирона развивается постепенно. Терапевтический эффект начинает проявляться между 7-м и 14-м днями терапии, а максимальный эффект достигается примерно через 4 недели после начала лечения. Поэтому этот препарат не назначается эпизодически. Рекомендованная начальная доза составляет 15 мг, эту дозу можно повышать на 5 мг в сутки каждые 2-3 дня. Обычная суточная доза 20-30 мг в сутки. Максимальная однократная доза составляет 30 мг, максимальная суточная не должна превышать 60 мг. Препарат может применяться в комбинированной терапии с другими средствами. Комментарии: в отличие от производных бензодиазепина буспирон не обладает миорелаксантными противосудорожными свойствами. Его седативный эффект значительно слабее эффекта типичных анксиолитиков и не нарушает психомоторные функции. По анксиолитической активности буспирон примерно аналогичен эффекту производных бензодиазепина. Буспирон не вызывает толерантности и зависимости, а после завершения курса лечения не развиваются симптомы отмены. Действие буспирона развивается постепенно. Терапевтический эффект начинает проявляться между 7-м и 14-м днями терапии, а максимальный эффект достигается примерно через 4 недели после начала лечения. Поэтому этот препарат не назначается эпизодически. Рекомендованная начальная доза составляет 15 мг, эту дозу можно повышать на 5 мг в сутки каждые 2-3 дня. Обычная суточная доза 20-30 мг в сутки. Максимальная однократная доза составляет 30 мг, максимальная суточная не должна превышать 60 мг. Препарат может применяться в ⟦стр. 22⟧ комбинированной терапии с другими средствами.
--- стр. 22 --- --- стр. 22 ---
@@ -513,7 +513,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- Пациентам с ПТСР рекомендовано использование когнитивной психотерапии длительностью 15-20 сессий, которые проводятся еженедельно индивидуально и/или в группе для модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию [130, 131]. - Пациентам с ПТСР рекомендовано использование когнитивной психотерапии длительностью 15-20 сессий, которые проводятся еженедельно индивидуально и/или в группе для модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию [130, 131].
Уровень убедительности рекомендаций A (уровень достоверности доказательств – 2). Уровень убедительности рекомендаций A (уровень достоверности доказательств – 2).
Комментарии: основная задача терапии – модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию. Комментарии: основная задача терапии – модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию.
Пациент под руководством психотерапевта обучается идентификации внутренних и внешних стимулов, а также специфических триггеров, поддерживающих симптомы ПТСР. С целью уменьшения выраженности интрузий проводится тщательная оценка воспоминаний и интегрирование травматического опыта. Для работы с дисфункциональными мыслями, связанными с оценкой травмы и глубинными убеждениями, поддерживающими ощущения постоянной угрозы, рекомендован сократовский диалог. Дополнительной мишенью являются дисфункциональные когнитивные и поведенческие паттерны, которые блокируют адаптивные копинг-стратегии и восстановление последовательных воспоминаний о травматическом событии, к примеру, руминации, поиск безопасности, подавление мыслей. Пациент под руководством психотерапевта обучается идентификации внутренних и внешних стимулов, а также специфических триггеров, поддерживающих симптомы ПТСР. С целью уменьшения выраженности интрузий проводится тщательная оценка воспоминаний и интегрирование травматического опыта. Для работы с дисфункциональными мыслями, связанными с оценкой травмы и глубинными убеждениями, поддерживающими ощущения постоянной угрозы, рекомендован сократовский диалог. Дополнительной мишенью являются дисфункциональные когнитивные и поведенческие паттерны, которые блокируют ⟦стр. 27⟧ адаптивные копинг-стратегии и восстановление последовательных воспоминаний о травматическом событии, к примеру, руминации, поиск безопасности, подавление мыслей.
--- стр. 27 --- --- стр. 27 ---
@@ -528,7 +528,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- Пациентам с ПТСР рекомендовано использование индивидуальной когнитивноповеденческой психотерапии с пролонгированной экспозицией – 15-20 сеансов с целью повышения переносимости неприятных стимулов, связанных с травматическим опытом, обучения пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой [140, 141, 142, 143, 144, 145, 146, 147, 148]. - Пациентам с ПТСР рекомендовано использование индивидуальной когнитивноповеденческой психотерапии с пролонгированной экспозицией – 15-20 сеансов с целью повышения переносимости неприятных стимулов, связанных с травматическим опытом, обучения пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой [140, 141, 142, 143, 144, 145, 146, 147, 148].
Уровень убедительности рекомендаций A (уровень достоверности доказательств 1). Уровень убедительности рекомендаций A (уровень достоверности доказательств 1).
Комментарии: основная цель этого метода повысить переносимость неприятных стимулов, связанных с травматическим опытом, она направлена на обучение пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой. Комментарии: основная цель этого метода повысить переносимость неприятных стимулов, связанных с травматическим опытом, она направлена на обучение пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой.
Основной задачей является обучение, что триггеры и воспоминания являются безопасными и переносимыми и их не стоит избегать. Длительность терапии составляет около 3 месяцев с еженедельными сессиями от 60 до 120 минут, всего проводится от 8 до 15 сессий, в ряде случаев рекомендовано от 15 до 20. В начале терапии врач-психотерапевт описывает план лечения и валидирует травматический опыт пациента, затем проводится обучение навыкам совладания с тревогой и дыхательным упражнениям. После этого проводится собственно экспозиция, для ее успешного проведения должен быть сформирован терапевтический альянс и атмосфера безопасности, где в условиях эмоциональной поддержки возможно столкновение с очень пугающими стимулами. Экспозиция может проводиться в воображении, или в качестве домашнего задания in vivo, в настоящее время активно используются программы виртуальной реальности (ВР) для проведения экспозиции. При проведении ВР экспозиции длительность сессии составляет 45-60 минут, каждая сцена повторяется до тех, пока уровень дистресса не снизится вдвое по сравнению с первым предъявлением. Следующая сцена используется после того, как пациент подтвердит свою готовность, задача терапии сделать дискомфорт переносимым. Темп психотерапии определяется состоянием и индивидуальными особенностями пациента [147, 148]. Основной задачей является обучение, что триггеры и воспоминания являются безопасными и переносимыми и их не стоит избегать. Длительность терапии составляет около 3 месяцев с еженедельными сессиями от 60 до 120 минут, всего проводится от 8 до 15 сессий, в ряде случаев рекомендовано от 15 до 20. В начале терапии врач-психотерапевт описывает план лечения и валидирует травматический опыт пациента, затем проводится обучение навыкам совладания с тревогой и дыхательным упражнениям. После этого проводится собственно экспозиция, для ее успешного проведения должен быть сформирован терапевтический альянс и атмосфера безопасности, где в условиях эмоциональной поддержки возможно столкновение с очень пугающими стимулами. Экспозиция может проводиться в воображении, или в качестве домашнего задания in vivo, в настоящее время активно используются программы виртуальной реальности (ВР) для проведения экспозиции. При проведении ВР экспозиции длительность сессии составляет 45-60 минут, каждая сцена ⟦стр. 28⟧ повторяется до тех, пока уровень дистресса не снизится вдвое по сравнению с первым предъявлением. Следующая сцена используется после того, как пациент подтвердит свою готовность, задача терапии сделать дискомфорт переносимым. Темп психотерапии определяется состоянием и индивидуальными особенностями пациента [147, 148].
--- стр. 28 --- --- стр. 28 ---
@@ -541,7 +541,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Комментарии: само название определяет основную цель терапии – формирование альтернативной оценки травматического опыта, который часто пациентами однозначно воспринимается как невыносимый и безысходный, поведенческий модуль направлен на выработку оптимальных паттернов поведения в процессе сопоставления различных, иногда противоречивых вариантов. Задача психотерапевта в каждом индивидуальном случае найти оптимальный баланс между принятием и изменением, для чего используются соответствующие техники, для решения отдельных задач могут дополнительно использоваться отдельные приемы ТФ-КПТ, психотерапии, сфокусированной на сострадании, психотерапии принятия и ответственности. В процессе диалектической бихевиоральной терапии ПТСР используются майндфуллнесс, обучение переносимости дистресса и навыкам эмоциональной регуляции, повышение межличностной эффективности, экспозиция и предотвращение ответа, противоположное поведение, валидация, самопринятие. Комментарии: само название определяет основную цель терапии – формирование альтернативной оценки травматического опыта, который часто пациентами однозначно воспринимается как невыносимый и безысходный, поведенческий модуль направлен на выработку оптимальных паттернов поведения в процессе сопоставления различных, иногда противоречивых вариантов. Задача психотерапевта в каждом индивидуальном случае найти оптимальный баланс между принятием и изменением, для чего используются соответствующие техники, для решения отдельных задач могут дополнительно использоваться отдельные приемы ТФ-КПТ, психотерапии, сфокусированной на сострадании, психотерапии принятия и ответственности. В процессе диалектической бихевиоральной терапии ПТСР используются майндфуллнесс, обучение переносимости дистресса и навыкам эмоциональной регуляции, повышение межличностной эффективности, экспозиция и предотвращение ответа, противоположное поведение, валидация, самопринятие.
- Пациентам с ПТСР рекомендовано использование "Десенсибилизации и переработки движениями глаз" (ДПДГ), длительностью 6-12 сессий для переработки травматического опыта [155, 156, 157, 158, 159, 160]. - Пациентам с ПТСР рекомендовано использование "Десенсибилизации и переработки движениями глаз" (ДПДГ), длительностью 6-12 сессий для переработки травматического опыта [155, 156, 157, 158, 159, 160].
Уровень убедительности рекомендаций A (уровень достоверности доказательств 2). Уровень убедительности рекомендаций A (уровень достоверности доказательств 2).
Комментарии: "Десенсибилизации и переработки движениями глаз" (ДПДГ) – метод конфронтации с травматическим опытом, с применением направленной билатеральной стимуляции (посредством ритмичных движений глазами) с одновременным образным представлением травматического события. Движения глаз и другие формы стимуляции двойного фокуса внимания, помимо глазных движений возможно использование звуковой стимуляции или постукивание по различным частям тела, обеспечивает одновременную десенсибилизацию и когнитивное переструктурирование, а также интеграцию травматических воспоминаний и уменьшение выраженности симптомов ПТСР. Это метод индивидуальной психотерапии длительностью 6-12 сессий, которые могут проводиться последовательно каждый день. Ф. Шапиро – автор метода основывается на том, что эмоциональная травма может нарушить работу системы переработки информации, поэтому она будет сохраняться в форме, обусловленной травматическим переживанием, и способствует формированию интрузионных симптомов посттравматического синдрома. Комментарии: "Десенсибилизации и переработки движениями глаз" (ДПДГ) – метод конфронтации с травматическим опытом, с применением направленной билатеральной стимуляции (посредством ритмичных движений глазами) с одновременным образным представлением травматического события. Движения глаз и другие формы стимуляции ⟦стр. 29⟧ двойного фокуса внимания, помимо глазных движений возможно использование звуковой стимуляции или постукивание по различным частям тела, обеспечивает одновременную десенсибилизацию и когнитивное переструктурирование, а также интеграцию травматических воспоминаний и уменьшение выраженности симптомов ПТСР. Это метод индивидуальной психотерапии длительностью 6-12 сессий, которые могут проводиться последовательно каждый день. Ф. Шапиро – автор метода основывается на том, что эмоциональная травма может нарушить работу системы переработки информации, поэтому она будет сохраняться в форме, обусловленной травматическим переживанием, и способствует формированию интрузионных симптомов посттравматического синдрома.
--- стр. 29 --- --- стр. 29 ---
@@ -654,7 +654,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Профилактика ПТСР заключается во внедрении принципов здорового образа жизни, мероприятий, направленных на поддержание психического здоровья среди населения, ограничение употребления алкоголя и психоактивных веществ. Профилактика ПТСР заключается во внедрении принципов здорового образа жизни, мероприятий, направленных на поддержание психического здоровья среди населения, ограничение употребления алкоголя и психоактивных веществ.
а) Непсихологические и немедикаментозные профилактические мероприятия для взрослых. а) Непсихологические и немедикаментозные профилактические мероприятия для взрослых.
В области психического здоровья в отечественной практике выстраивание системы профилактики принимает форму психологического просвещения населения. В области психического здоровья в отечественной практике выстраивание системы профилактики принимает форму психологического просвещения населения.
Психологическое просвещение является активной формой психологической профилактики, основными задачами которой являются: Психологическое просвещение является активной формой психологической ⟦стр. 34⟧ профилактики, основными задачами которой являются:
--- стр. 34 --- --- стр. 34 ---
@@ -672,7 +672,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
Комментарии: Меры вторичной профилактики могут быть реализованы после воздействия травмирующего фактора (наступления травмирующего события), отвечающего критериям, потенциально влекущим формирование ПТСР (внезапность, субъективно переживаемая угроза жизни, утрата). В отечественной практике вторичную профилактику условно можно обозначить двумя направлениями частично взаимосвязанными друг с другом содержанием и являющимися при этом универсальными рекомендациями для переживших травматическое событие: экстренная психологическая помощь и психологическое просвещение. Экстренная психологическая помощь (ЭПП) включает в себя также информационно-психологическую поддержку, индивидуальную, психологическую помощь. Комментарии: Меры вторичной профилактики могут быть реализованы после воздействия травмирующего фактора (наступления травмирующего события), отвечающего критериям, потенциально влекущим формирование ПТСР (внезапность, субъективно переживаемая угроза жизни, утрата). В отечественной практике вторичную профилактику условно можно обозначить двумя направлениями частично взаимосвязанными друг с другом содержанием и являющимися при этом универсальными рекомендациями для переживших травматическое событие: экстренная психологическая помощь и психологическое просвещение. Экстренная психологическая помощь (ЭПП) включает в себя также информационно-психологическую поддержку, индивидуальную, психологическую помощь.
Экстренная психологическая помощь (Emergency psychological aid) – целостная система мероприятий, направленных на оптимизацию актуального психического состояния, включающая в себя как отдельные специальные методы психологического воздействия, так и организацию особой среды, которая помогает в снижении интенсивности воздействия травматического события на человека. ЭПП может быть оказана человеку во времени максимально приближенно от момента воздействия травмирующего события. Экстренная психологическая помощь (Emergency psychological aid) – целостная система мероприятий, направленных на оптимизацию актуального психического состояния, включающая в себя как отдельные специальные методы психологического воздействия, так и организацию особой среды, которая помогает в снижении интенсивности воздействия травматического события на человека. ЭПП может быть оказана человеку во времени максимально приближенно от момента воздействия травмирующего события.
Задачи такого вида психологической помощи включают в себя удовлетворение актуальных потребностей человека (информация, социальная поддержка и т.д.), в том числе переживание травматического опыта, а в качестве цели ставит сохранение психологических и физических ресурсов человека, переживающего травматическое событие [224, 225]. Задачи такого вида психологической помощи включают в себя удовлетворение актуальных потребностей человека (информация, социальная поддержка и т.д.), в том числе переживание травматического опыта, а в качестве цели ставит сохранение психологических ⟦стр. 35⟧ и физических ресурсов человека, переживающего травматическое событие [224, 225].
--- стр. 35 --- --- стр. 35 ---
@@ -687,7 +687,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
218, 219, 220, 228, 229]. 218, 219, 220, 228, 229].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
Комментарии: индивидуальная психологическая помощь (ИПП) представляет собой форму работы, направленную на актуализацию личностных ресурсов, принятие сложившейся ситуации, поиск и актуализацию стратегий совладания, регуляцию актуального психического состояния. Одним из инструментов ЭПП является фасилитация эмоционального отреагирования человека относительно произошедшего травматического события. Номинация эмоционального состояния, которая происходит в ходе оказания ЭПП, позволяет снизить влияние травматических переживаний на состояние человека непосредственно после произошедшего, актуализировать ресурсы, необходимые для совладания со сложившейся ситуацией [228]. Комментарии: индивидуальная психологическая помощь (ИПП) представляет собой форму работы, направленную на актуализацию личностных ресурсов, принятие сложившейся ситуации, поиск и актуализацию стратегий совладания, регуляцию актуального психического состояния. Одним из инструментов ЭПП является фасилитация эмоционального отреагирования человека относительно произошедшего травматического события. Номинация эмоционального состояния, которая происходит в ходе оказания ЭПП, позволяет снизить влияние травматических переживаний на состояние человека непосредственно после произошедшего, актуализировать ресурсы, необходимые для совладания со сложившейся ситуацией [228].
Также сохранение социальной активности, выстраивание сети социальной поддержки и актуализация социальных связей может быть обоснованной профилактической практикой для людей, переживающих травматическое событие, особенно в контексте изменения базовых позитивных убеждений в отношении мира и окружения. Обнаружены свидетельства о положительном влиянии социальной поддержки, в том числе и в виде отношений с близкими, на способность человека справляться с травматической ситуацией [216, 217, 218]. Также сохранение социальной активности, выстраивание сети социальной поддержки и актуализация социальных связей может быть обоснованной профилактической практикой для людей, переживающих травматическое событие, особенно в контексте изменения ⟦стр. 36⟧ базовых позитивных убеждений в отношении мира и окружения. Обнаружены свидетельства о положительном влиянии социальной поддержки, в том числе и в виде отношений с близкими, на способность человека справляться с травматической ситуацией [216, 217, 218].
--- стр. 36 --- --- стр. 36 ---
@@ -704,7 +704,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- В отношении лиц, столкнувшихся с чрезвычайным, катастрофическим стрессом, рекомендуется проводить психологические интервенции с целью снижения риска ПТСР - В отношении лиц, столкнувшихся с чрезвычайным, катастрофическим стрессом, рекомендуется проводить психологические интервенции с целью снижения риска ПТСР
[224, 225, 226, 227]. [224, 225, 226, 227].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
Комментарии: Одной из самых сложных задач является выявление людей, нуждающихся в помощи, потому что взаимосвязь между острыми и хроническими стрессовыми реакциями сложна и обуславливается множеством факторов, которые не могут быть с точностью проиндексированы в острой фазе. Проблемы, вызванные переживанием боли, потерей дохода, прерыванием трудовой деятельности или смертью близкого, общее социальное неблагополучие, которое часто сопровождает травмирующее событие, могут отвлечь человека от предложения провести интервенцию. Лишь небольшая часть остро травмированных людей на самом деле принимает предложения о лечении [235]. В связи с этим особо важное значение имеет необходимость постоянного мониторинга некоторых подгрупп после травмы вместо того, чтобы делать акцент на раннем вмешательстве. Тем не менее существует несколько подходов, принятых эффективными на ранних этапах вмешательства. Комментарии: Одной из самых сложных задач является выявление людей, нуждающихся в помощи, потому что взаимосвязь между острыми и хроническими стрессовыми реакциями сложна и обуславливается множеством факторов, которые не могут быть с точностью проиндексированы в острой фазе. Проблемы, вызванные переживанием боли, потерей дохода, прерыванием трудовой деятельности или смертью близкого, общее социальное неблагополучие, которое часто сопровождает травмирующее событие, могут отвлечь человека от предложения провести интервенцию. Лишь небольшая часть остро травмированных людей на самом деле принимает предложения о лечении [235]. В связи с этим особо важное значение имеет необходимость постоянного мониторинга некоторых подгрупп после травмы вместо того, чтобы делать акцент на раннем вмешательстве. Тем не ⟦стр. 37⟧ менее существует несколько подходов, принятых эффективными на ранних этапах вмешательства.
--- стр. 37 --- --- стр. 37 ---
@@ -729,7 +729,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
1. Принцип раннего выявления психических расстройств: 1. Принцип раннего выявления психических расстройств:
Способность оказать своевременную психотерапевтическую и психиатрическую помощь на ранних этапах развития психических нарушений зависит от того, насколько быстро выявляются лица, пострадавшие в ходе чрезвычайной ситуации. Максимально раннее выявление лиц, не только с уже сформировавшимися психическими расстройствами, но и нарушениями субклинического уровня, предотвращает углубление психических расстройств и их хронификацию, способствует профилактике развития социальной дезадаптации. Раннему выявлению также способствует широкое информирование населения о причинах и клинических проявлениях ПТСР, об учреждениях, оказывающих медицинскую и психологическую помощь, о возможностях обращать по телефонам "горячей линии". Способность оказать своевременную психотерапевтическую и психиатрическую помощь на ранних этапах развития психических нарушений зависит от того, насколько быстро выявляются лица, пострадавшие в ходе чрезвычайной ситуации. Максимально раннее выявление лиц, не только с уже сформировавшимися психическими расстройствами, но и нарушениями субклинического уровня, предотвращает углубление психических расстройств и их хронификацию, способствует профилактике развития социальной дезадаптации. Раннему выявлению также способствует широкое информирование населения о причинах и клинических проявлениях ПТСР, об учреждениях, оказывающих медицинскую и психологическую помощь, о возможностях обращать по телефонам "горячей линии".
2. Принцип доступности медико-психологической помощи: 2. Принцип доступности медико-психологической помощи:
В условиях чрезвычайной ситуации (ЧС) резко увеличивается число нуждающихся в помощи при ограниченном количестве специалистов, способных оказать необходимую помощь. Целесообразно организовывать "горячие линии". Куда могут обращаться как пациенты, так и специалист, получать психологическую помощи и информацию об организации медицинской и психологической помощи при ПТСР, учреждениях ее оказывающих. При этом помощь должна носить не разовый, а постоянный характер с учетом возможности длительного течения психических расстройств. Наиболее оптимальной представляется организация медико-психиатрической В условиях чрезвычайной ситуации (ЧС) резко увеличивается число нуждающихся в помощи при ограниченном количестве специалистов, способных оказать необходимую ⟦стр. 38⟧ помощь. Целесообразно организовывать "горячие линии". Куда могут обращаться как пациенты, так и специалист, получать психологическую помощи и информацию об организации медицинской и психологической помощи при ПТСР, учреждениях ее оказывающих. При этом помощь должна носить не разовый, а постоянный характер с учетом возможности длительного течения психических расстройств. Наиболее оптимальной представляется организация медико-психиатрической
--- стр. 38 --- --- стр. 38 ---
@@ -745,7 +745,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Основная цель психотерапии лиц с ПТСР, длительное время находившихся в условиях Основная цель психотерапии лиц с ПТСР, длительное время находившихся в условиях
ЧС, заключается в восстановлении удовлетворительного уровня функционирования. Для этого необходимы некоторые дополнительные ресурсы "Я" или более гибкие эмоциональные, когнитивные и поведенческие реакции, позволяющие справляться с имеющимися последствиями психических травм и будущими стрессами. Важной составляющей в преодолении пережитых психотравмирующих событий являются убеждения и верования пациентов. Психотерапия и психологическая коррекция способствуют переосмыслению произошедшего и формирует основу для адаптивного разрешения психотравмирующей ситуации. ЧС, заключается в восстановлении удовлетворительного уровня функционирования. Для этого необходимы некоторые дополнительные ресурсы "Я" или более гибкие эмоциональные, когнитивные и поведенческие реакции, позволяющие справляться с имеющимися последствиями психических травм и будущими стрессами. Важной составляющей в преодолении пережитых психотравмирующих событий являются убеждения и верования пациентов. Психотерапия и психологическая коррекция способствуют переосмыслению произошедшего и формирует основу для адаптивного разрешения психотравмирующей ситуации.
6. Принцип динамического сопровождения: 6. Принцип динамического сопровождения:
Очень часто пациенты с ПТСР прекращают посещение врача и/или прием медикаментов после некоторого улучшения состояния, что влечет за собой обострение психопатологической симптоматики и ухудшение состояния. Как следствие этого у пациентов появляется разочарование в проводимом лечении и снижается доверие к лечащему врачу. При организации психотерапевтической помощи на уровне первичного медицинского звена появляется возможность наблюдения за состоянием пациента в период посещения им поликлиники или во время патронажного медсестринского обхода. Очень часто пациенты с ПТСР прекращают посещение врача и/или прием медикаментов после некоторого улучшения состояния, что влечет за собой обострение психопатологической симптоматики и ухудшение состояния. Как следствие этого у пациентов появляется разочарование в проводимом лечении и снижается доверие к лечащему врачу. При организации психотерапевтической помощи на уровне первичного ⟦стр. 39⟧ медицинского звена появляется возможность наблюдения за состоянием пациента в период посещения им поликлиники или во время патронажного медсестринского обхода.
--- стр. 39 --- --- стр. 39 ---
@@ -847,7 +847,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- появление особой ранимости и сенситивности, повышенной чувствительности к несправедливости, легкость возникновения чувства вины перед боевыми товарищами; - появление особой ранимости и сенситивности, повышенной чувствительности к несправедливости, легкость возникновения чувства вины перед боевыми товарищами;
- низкая восприимчивость к малозначимым раздражителям или обстоятельствам, требующим дополнительных психических затрат, в том числе регресс высших личностных свойств со снижением восприимчивости к чужим страданиям и смерти, уходом от решения нравственных проблем, облегчающих господство групповых ценностей; - низкая восприимчивость к малозначимым раздражителям или обстоятельствам, требующим дополнительных психических затрат, в том числе регресс высших личностных свойств со снижением восприимчивости к чужим страданиям и смерти, уходом от решения нравственных проблем, облегчающих господство групповых ценностей;
- ослабление интрапсихической переработки впечатлений, снижение способности к вербализации впечатлений и переживаний. - ослабление интрапсихической переработки впечатлений, снижение способности к вербализации впечатлений и переживаний.
Подобные эмоциональные изменения, особенности переживания значимых событий, коммуникации и системы ценностных образований формируются на фоне длительного воздействия боевых стресс-факторов и становятся своего рода приспособлением к реалиям жизни на войне. Наряду с общими психологическими характеристиками в каждом конкретном случае имеются и свои нюансы, что зависит от изначально присущих особенностей личности и конкретного содержания психотравмирующих обстоятельств. Подобные эмоциональные изменения, особенности переживания значимых событий, коммуникации и системы ценностных образований формируются на фоне длительного ⟦стр. 43⟧ воздействия боевых стресс-факторов и становятся своего рода приспособлением к реалиям жизни на войне. Наряду с общими психологическими характеристиками в каждом конкретном случае имеются и свои нюансы, что зависит от изначально присущих особенностей личности и конкретного содержания психотравмирующих обстоятельств.
--- стр. 43 --- --- стр. 43 ---
@@ -882,7 +882,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- Незамедлительность (Immediate) – начало ее сразу, как только у солдата снижается боеспособность, а не после наступления общей дезадаптации; - Незамедлительность (Immediate) – начало ее сразу, как только у солдата снижается боеспособность, а не после наступления общей дезадаптации;
- Централизованность (Central) – осуществление помощи в определенном месте, отдельно от терапевтических и хирургических пациентов или Контактность (Contact) – координацию контактов между солдатом и его подразделением и командирами с целью ориентации обеих сторон на коммуникацию, осознание и проявление взаимной необходимости с целью быстрой реинтеграции; - Централизованность (Central) – осуществление помощи в определенном месте, отдельно от терапевтических и хирургических пациентов или Контактность (Contact) – координацию контактов между солдатом и его подразделением и командирами с целью ориентации обеих сторон на коммуникацию, осознание и проявление взаимной необходимости с целью быстрой реинтеграции;
- Выжидание (Expectant) – намерение при первой возможности возвратить солдата в строй, - Выжидание (Expectant) – намерение при первой возможности возвратить солдата в строй,
- Проксимальность (Proximal) – проведение коррекции максимально ближе к подразделению, в котором служит пострадавший, - Проксимальность (Proximal) – проведение коррекции максимально ближе к ⟦стр. 45⟧ подразделению, в котором служит пострадавший,
--- стр. 45 --- --- стр. 45 ---
@@ -895,7 +895,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Комментарии: Условием эффективности оказания психиатрической помощи военнослужащим, подвергшимся воздействию боевого стресса, является осуществление в войсковом звене медицинской сортировки по уровневому принципу (предболезненный, пограничный, психотический) и по предполагаемой длительности дезадаптации с выделением трех групп пострадавших [35, 274, 275]: Комментарии: Условием эффективности оказания психиатрической помощи военнослужащим, подвергшимся воздействию боевого стресса, является осуществление в войсковом звене медицинской сортировки по уровневому принципу (предболезненный, пограничный, психотический) и по предполагаемой длительности дезадаптации с выделением трех групп пострадавших [35, 274, 275]:
- 1-я группа (кратковременно выбывшие из строя) – эти военнослужащие переносят предболезненные, чаще всего синдромально незавершенные нарушения адаптации или реакции на стресс, которые при своевременной поддержке имеют транзиторный, очень кратковременный характер. Помощь этой категории пострадавших военнослужащих следует оказывать на 1-м этапе психиатрической помощи – в непосредственной близости от своих воинских подразделений на основе принципов BICEPS. Подлежат возврату в свои подразделения в срок до 1 суток. - 1-я группа (кратковременно выбывшие из строя) – эти военнослужащие переносят предболезненные, чаще всего синдромально незавершенные нарушения адаптации или реакции на стресс, которые при своевременной поддержке имеют транзиторный, очень кратковременный характер. Помощь этой категории пострадавших военнослужащих следует оказывать на 1-м этапе психиатрической помощи – в непосредственной близости от своих воинских подразделений на основе принципов BICEPS. Подлежат возврату в свои подразделения в срок до 1 суток.
- 2-я группа (уже представляет собой санитарные потери) – это пострадавшие с кратковременными стрессовыми реакциями, нуждающиеся в лечении до 5-7 суток и получающие помощь на 2-м этапе психиатрической помощи – силами Групп психического здоровья (ГПЗ), приданных лечебным учреждениям, дислоцированным в зоне боевых действий (например, Медицинский отряд специального назначения, МОСН). Помощь им оказывается также на основе принципов BICEPS. - 2-я группа (уже представляет собой санитарные потери) – это пострадавшие с кратковременными стрессовыми реакциями, нуждающиеся в лечении до 5-7 суток и получающие помощь на 2-м этапе психиатрической помощи – силами Групп психического здоровья (ГПЗ), приданных лечебным учреждениям, дислоцированным в зоне боевых действий (например, Медицинский отряд специального назначения, МОСН). Помощь им оказывается также на основе принципов BICEPS.
- 3-я группа (санитарные потери) – это военнослужащие с затяжными и тяжелыми психическими расстройствами, нуждающиеся в более длительном стационарном лечении и эвакуации на последующие этапы – в психиатрические отделения военных госпиталей или, в крайнем случае и при большей доступности, в ближайшие гражданские психиатрические стационары. - 3-я группа (санитарные потери) – это военнослужащие с затяжными и тяжелыми психическими расстройствами, нуждающиеся в более длительном стационарном лечении и ⟦стр. 46⟧ эвакуации на последующие этапы – в психиатрические отделения военных госпиталей или, в крайнем случае и при большей доступности, в ближайшие гражданские психиатрические стационары.
--- стр. 46 --- --- стр. 46 ---
@@ -904,7 +904,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
- Лечение военнослужащих, подвергшихся воздействию боевого стресса, на 1-м и 2-м этапам медицинской эвакуации (в войсковом звене медицинской помощи) с целью вторичной профилактики ПТСР рекомендовано проводить с акцентом на предоставление безопасности, отдыха, сна, питания, с минимальным использованием лекарственных средств и ограниченным применением краткосрочных методов психотерапии [35, 274, 275]. - Лечение военнослужащих, подвергшихся воздействию боевого стресса, на 1-м и 2-м этапам медицинской эвакуации (в войсковом звене медицинской помощи) с целью вторичной профилактики ПТСР рекомендовано проводить с акцентом на предоставление безопасности, отдыха, сна, питания, с минимальным использованием лекарственных средств и ограниченным применением краткосрочных методов психотерапии [35, 274, 275].
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5). Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
Комментарии: на 1-м этапе сортировки следует насколько можно избегать установки диагноза, так как здесь нет специалистов-психиатров, нет времени на дифференциальную диагностику, нужно избежать стигматизации и идентификации пострадавшего с ролью пациента. На 1-м этапе лечение должно быть простым и кратковременным. Купируется возбуждение. Обязательно обеспечить сон, так как его восстановление оказывается необходимым условием обратного развития многих реакций не стресс и почти всегда – реакций предболезненного уровня. Задачей лекарственной терапии является обеспечение эвакуации пациентов с тяжелыми психическими расстройствами. Психотерапия возможна, если потери невелики и число пострадавших, поступающих на этапы эвакуации, очень небольшое. Важной составляющей помощи является выявление признаков черепно-мозговой травмы и мониторинг состояния контуженных. Появление анизокории, признаков даже легкой спутанности или любого ухудшения физического или психического состояния контуженного требует немедленной эвакуации, так как при отсутствии лечения быстро может наступить угнетение сознания и остановка дыхания. Решение об эвакуации на 2-й этап психиатрической помощи принимается, только если спустя сутки или двое у военнослужащего сохраняются выраженные симптомы реакции на стресс, расстройства адаптации или имеются признаки контузии и, самое главное, явно нарушена боеспособность. Комментарии: на 1-м этапе сортировки следует насколько можно избегать установки диагноза, так как здесь нет специалистов-психиатров, нет времени на дифференциальную диагностику, нужно избежать стигматизации и идентификации пострадавшего с ролью пациента. На 1-м этапе лечение должно быть простым и кратковременным. Купируется возбуждение. Обязательно обеспечить сон, так как его восстановление оказывается необходимым условием обратного развития многих реакций не стресс и почти всегда – реакций предболезненного уровня. Задачей лекарственной терапии является обеспечение эвакуации пациентов с тяжелыми психическими расстройствами. Психотерапия возможна, если потери невелики и число пострадавших, поступающих на этапы эвакуации, очень небольшое. Важной составляющей помощи является выявление признаков черепно-мозговой травмы и мониторинг состояния контуженных. Появление анизокории, признаков даже легкой спутанности или любого ухудшения физического или психического состояния контуженного требует немедленной эвакуации, так как при отсутствии лечения быстро может наступить угнетение сознания и остановка дыхания. Решение об эвакуации на 2-й этап психиатрической помощи принимается, только если спустя сутки или двое у военнослужащего сохраняются выраженные симптомы реакции на стресс, расстройства адаптации или имеются признаки контузии и, самое главное, явно нарушена боеспособность.
2-й этап помощи может быть представлен медицинскими подразделениями, например, медицинским отрядам специального назначения, которым должны быть приданы группы медико-психологического сопровождения (в составе врача-психиатра и медицинского психолога), и где может быть проведена диагностика психического расстройства и оказан весь объем необходимой помощи. Однако надо ясно определиться, что при отсутствии специалистов психиатров МОСН не может выполнять функции 2-го этапа медицинской помощи пострадавшим с психическими расстройствами. На практике часто бывает так, что функции 1-го и 2-го этапов психиатрической помощи смешиваются, но это ухудшает исходы реакций боевого стресса и повышает риск развития ПТСР. Выбор применяемых на II этапе методов коррекции определяется необходимостью быстрого лечения большого числа пострадавших. Психотерапия, если есть возможность ее применять, должна быть очень кратковременная, отличаться простотой, а ее эффективность – в наименьшей степени зависеть от искусства конкретного специалиста. При выборе средств фармакологической коррекции предпочтение по-прежнему должно отдаваться препаратам производным бензодиазепина. Последнее время в качестве средства предупреждения ПТСР предлагается 2-й этап помощи может быть представлен медицинскими подразделениями, например, медицинским отрядам специального назначения, которым должны быть приданы группы медико-психологического сопровождения (в составе врача-психиатра и медицинского психолога), и где может быть проведена диагностика психического расстройства и оказан весь объем необходимой помощи. Однако надо ясно определиться, что при отсутствии специалистов психиатров МОСН не может выполнять функции 2-го этапа медицинской помощи пострадавшим с психическими расстройствами. На практике часто бывает так, что функции 1-го и 2-го этапов психиатрической помощи смешиваются, но это ухудшает исходы реакций боевого стресса и повышает риск развития ПТСР. Выбор применяемых на II этапе методов коррекции определяется необходимостью быстрого лечения большого числа ⟦стр. 47⟧ пострадавших. Психотерапия, если есть возможность ее применять, должна быть очень кратковременная, отличаться простотой, а ее эффективность – в наименьшей степени зависеть от искусства конкретного специалиста. При выборе средств фармакологической коррекции предпочтение по-прежнему должно отдаваться препаратам производным бензодиазепина. Последнее время в качестве средства предупреждения ПТСР предлагается
--- стр. 47 --- --- стр. 47 ---
@@ -916,7 +916,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
Отсутствие рекомендаций применения антидепрессантов объясняется недостатком исследований их эффективности в ближайшем после травмирующего стресса периоде, а также отсроченным терапевтическим эффектом этой группы лекарственных средств, что делает бесперспективным их назначение на этапах медицинской эвакуации. Отсутствие рекомендаций применения антидепрессантов объясняется недостатком исследований их эффективности в ближайшем после травмирующего стресса периоде, а также отсроченным терапевтическим эффектом этой группы лекарственных средств, что делает бесперспективным их назначение на этапах медицинской эвакуации.
Отсутствие рекомендаций применения антипсихотиков объясняется высоким развитием побочных эффектов и негативным влиянием на боеспособность военнослужащих, которые, как ожидается, в ближайшее время вернуться в строй. Применение антипсихотиков поэтому допустимо лишь для купирования возбуждения и обеспечения эвакуации пациентов с психотическими расстройствами. Отсутствие рекомендаций применения антипсихотиков объясняется высоким развитием побочных эффектов и негативным влиянием на боеспособность военнослужащих, которые, как ожидается, в ближайшее время вернуться в строй. Применение антипсихотиков поэтому допустимо лишь для купирования возбуждения и обеспечения эвакуации пациентов с психотическими расстройствами.
Низкий уровень доказательности связан с минимально доступным количеством проведенных научных исследований, что объясняется крайней затруднительностью их проведения в условиях военных действий. Низкий уровень доказательности связан с минимально доступным количеством проведенных научных исследований, что объясняется крайней затруднительностью их проведения в условиях военных действий.
- Военнослужащим, поступающим на 2-й этап медицинской эвакуации в связи с острыми реакциями на стресс, расстройствами адаптации, особенно сочетающимися с признаками или анамнестическими данными о перенесенной контузии, после исключения противопоказаний, рекомендовано применение #гидрокортизона** (20-40 мг/сут) перорально для ослабления тревоги, неврологической симптоматики и снижения риска последующего возникновения ПТСР [275, 276, 277, 278, 279, 280, 282, 283, 284]. - Военнослужащим, поступающим на 2-й этап медицинской эвакуации в связи с острыми реакциями на стресс, расстройствами адаптации, особенно сочетающимися с признаками или анамнестическими данными о перенесенной контузии, после исключения противопоказаний, рекомендовано применение #гидрокортизона** (20-40 мг/сут) ⟦стр. 48⟧ перорально для ослабления тревоги, неврологической симптоматики и снижения риска последующего возникновения ПТСР [275, 276, 277, 278, 279, 280, 282, 283, 284].
--- стр. 48 --- --- стр. 48 ---
@@ -984,7 +984,7 @@ N
Список литературы Список литературы
1. ПСИХОТЕРАПИЯ: УЧЕБНИК/под ред. А.В. Васильевой, Т.А. Караваевой, Н.Г. 1. ПСИХОТЕРАПИЯ: УЧЕБНИК/под ред. А.В. Васильевой, Т.А. Караваевой, Н.Г.
Незнанова. – Москва: ГЭОТАР-Медиа, 2022. – 864 с.: ил. – DOI: 10.33029/9704-6485-4-VKN2022-1-86 Незнанова. – Москва: ГЭОТАР-Медиа, 2022. – 864 с.: ил. – DOI: 10.33029/9704-6485-4-VKN2022-1-86
2. МКБ. Международная классификация болезней, 10-й пересмотр. Психические расстройства и расстройства поведения. Класс V МКБ-10, адаптированный для использования в Российской Федерации, М.: Российское общество психиатров, 1998. 2. МКБ. Международная классификация болезней, 10-й пересмотр. Психические расстройства и расстройства поведения. Класс V МКБ-10, адаптированный для ⟦стр. 50⟧ использования в Российской Федерации, М.: Российское общество психиатров, 1998.
--- стр. 50 --- --- стр. 50 ---
@@ -1070,7 +1070,7 @@ Nov; 56(4). P. 340-350.
2012. Mar 18; 2(2). e000312. 2012. Mar 18; 2(2). e000312.
38. Taylor B.C., Hagel E.M., Carlson K.F. et al. Prevalence and costs of co-occurring traumatic brain injury with and without psychiatric disturbance and pain among Afghanistan and 38. Taylor B.C., Hagel E.M., Carlson K.F. et al. Prevalence and costs of co-occurring traumatic brain injury with and without psychiatric disturbance and pain among Afghanistan and
Iraq War Veteran V.A. users. Med. Care. 2012. Apr; 50(4). P. 342-346. Iraq War Veteran V.A. users. Med. Care. 2012. Apr; 50(4). P. 342-346.
39. Wilk J.E., Herrell R.K., Wynn G.H. et al. Mild traumatic brain injury (concussion), posttraumatic stress disorder, and depression in U.S. Soldiers involved in combat deployments: association with postdeployment symptoms. Psychosom. Med. 2012. Apr; 74(3). P. 249-257 39. Wilk J.E., Herrell R.K., Wynn G.H. et al. Mild traumatic brain injury (concussion), ⟦стр. 52⟧ posttraumatic stress disorder, and depression in U.S. Soldiers involved in combat deployments: association with postdeployment symptoms. Psychosom. Med. 2012. Apr; 74(3). P. 249-257
--- стр. 52 --- --- стр. 52 ---
@@ -1104,7 +1104,7 @@ JPsychotraumatol. 10(1): 169548
53. Hoskins M. D. et al. Pharmacological therapy for post-traumatic stress disorder: a systematic review and meta-analysis of monotherapy, augmentation and head-to-head approaches//European Journal of Psychotraumatology. – 2021. – T. 12. – N. 1. – C. 1802920. 53. Hoskins M. D. et al. Pharmacological therapy for post-traumatic stress disorder: a systematic review and meta-analysis of monotherapy, augmentation and head-to-head approaches//European Journal of Psychotraumatology. – 2021. – T. 12. – N. 1. – C. 1802920.
54. Lee DJ, Schnitzlein CW, Wolf JP et al. Psychotherapy versus pharmacotherapy for posttraumatic stress disorder: systemic review and meta-analyses to determine first-line treatments. 54. Lee DJ, Schnitzlein CW, Wolf JP et al. Psychotherapy versus pharmacotherapy for posttraumatic stress disorder: systemic review and meta-analyses to determine first-line treatments.
Depress Anxiety 2016; 33: 792-806. Depress Anxiety 2016; 33: 792-806.
55. Merz J, Schwarzer G, Gerger H. Comparative efficacy and acceptability of pharmacological, psychotherapeutic, and combination treatments in adults with posttraumatic stress disorder: a network meta-analysis. JAMA PsychiatryPsychiatry 2019; 76: 904 – 13.]. 55. Merz J, Schwarzer G, Gerger H. Comparative efficacy and acceptability of pharmacological, psychotherapeutic, and combination treatments in adults with posttraumatic stress ⟦стр. 53⟧ disorder: a network meta-analysis. JAMA PsychiatryPsychiatry 2019; 76: 904 – 13.].
--- стр. 53 --- --- стр. 53 ---
@@ -1137,7 +1137,7 @@ Eur J Clin Pharmacol 1982; 22: 2: 137-142;
71. Servant D, Graziani P, Moyse D, Parquet P. Treatment of adjustment disorder with anxiety: efficacy and tolerance of etifoxine in a doubleblind controlled study. Encephale. 1998 NovDec; 24(6): 569 – 74; 71. Servant D, Graziani P, Moyse D, Parquet P. Treatment of adjustment disorder with anxiety: efficacy and tolerance of etifoxine in a doubleblind controlled study. Encephale. 1998 NovDec; 24(6): 569 – 74;
72. Nguyen N, Fakra E, Pradel V, et al. Efficacy of etifoxine compared to lorazepam monotherapy in the treatment of patients with adjustment disorders with anxiety: a double-blind controlled study in general practice. Hum Psychopharmacol. 2006 Apr; 21(3): 139 – 49; 72. Nguyen N, Fakra E, Pradel V, et al. Efficacy of etifoxine compared to lorazepam monotherapy in the treatment of patients with adjustment disorders with anxiety: a double-blind controlled study in general practice. Hum Psychopharmacol. 2006 Apr; 21(3): 139 – 49;
73. Cottin J, Gouraud A, Jean-Pastor M, et al. Safety profile of etifoxine: A French pharmacovigilance survey. Fundam Clin Pharmacol. 2016 Apr; 30(2): 147 – 52. doi:10.1111/fcp.12169.Epub 2016 Jan 26; 73. Cottin J, Gouraud A, Jean-Pastor M, et al. Safety profile of etifoxine: A French pharmacovigilance survey. Fundam Clin Pharmacol. 2016 Apr; 30(2): 147 – 52. doi:10.1111/fcp.12169.Epub 2016 Jan 26;
74. Micallef J, Soubrouillard C, Guet F, et al. A double blind parallel group placebo controlled comparison of sedative and mnesic effects of etifoxine and lorazepam in healthy subjects 74. Micallef J, Soubrouillard C, Guet F, et al. A double blind parallel group placebo controlled ⟦стр. 54⟧ comparison of sedative and mnesic effects of etifoxine and lorazepam in healthy subjects
--- стр. 54 --- --- стр. 54 ---
@@ -1227,7 +1227,7 @@ PTSD: A double-blind, placebo-controlled study. Am J Psychiatry 2002; 159(10): 1
Психиатрия и психофармакотерапия. 2016; 18 (2): 26-32 Психиатрия и психофармакотерапия. 2016; 18 (2): 26-32
108. Беккер Р.А., Быков Ю.В. Клиническое применение перициазина (Неулептила). 108. Беккер Р.А., Быков Ю.В. Клиническое применение перициазина (Неулептила).
Психиатрия и психофармакотерапия. 2018; 20 (2): 16-31. Психиатрия и психофармакотерапия. 2018; 20 (2): 16-31.
109. Stahl SM. Stahl"s essential psychopharmacology: Case studies. Cambridge university press, 2015. Stahl SM. Stahl"s essential psychopharmacology: neuroscientific basis and practical applications. Cambridge university press, 2013. 109. Stahl SM. Stahl"s essential psychopharmacology: Case studies. Cambridge university press, 2015. Stahl SM. Stahl"s essential psychopharmacology: neuroscientific basis and practical ⟦стр. 56⟧ applications. Cambridge university press, 2013.
--- стр. 56 --- --- стр. 56 ---
@@ -1383,7 +1383,7 @@ Frontiers in Psychology, 8. doi:10.3389/fpsyg.2017.01668
166. Lewis, C., Roberts, N. P., Andrew, M., Starling, E., & Bisson, J. I. (2020). Psychological therapies for post-traumatic stress disorder in adults: systematic review and meta-analysis. 166. Lewis, C., Roberts, N. P., Andrew, M., Starling, E., & Bisson, J. I. (2020). Psychological therapies for post-traumatic stress disorder in adults: systematic review and meta-analysis.
European Journal of Psychotraumatology, 11(1), 1729633. doi:10.1080/20008198.2020.1729633 European Journal of Psychotraumatology, 11(1), 1729633. doi:10.1080/20008198.2020.1729633
167. Khan, A.M., Dar, S., Ahmed, R., Bachu, R., Adnan, M., & Kotapati, V. P. (2018). 167. Khan, A.M., Dar, S., Ahmed, R., Bachu, R., Adnan, M., & Kotapati, V. P. (2018).
Cognitive Behavioral Therapy versus Eye Movement Desensitization and Reprocessing in Patients with Posttraumatic Stress Disorder: Systematic Review and Meta-analysis of Randomized Clinical Cognitive Behavioral Therapy versus Eye Movement Desensitization and Reprocessing in Patients ⟦стр. 60⟧ with Posttraumatic Stress Disorder: Systematic Review and Meta-analysis of Randomized Clinical
--- стр. 60 --- --- стр. 60 ---
@@ -1509,7 +1509,7 @@ Vol. 99, N 26. P. e20700.
Posttraumatic Stress Disorder: A Systematic Review and Meta-Analysis//J Trauma Dissociation. Posttraumatic Stress Disorder: A Systematic Review and Meta-Analysis//J Trauma Dissociation.
2018. Vol. 19, N 1. P. 39-58. 2018. Vol. 19, N 1. P. 39-58.
214. B. A. van der Kolk, The body keeps the score: Mind, brain and body in the transformation of trauma. 2014. 214. B. A. van der Kolk, The body keeps the score: Mind, brain and body in the transformation of trauma. 2014.
215. J.W. Hopper, P.A. Frewen, B.A. van der Kolk, and R.A. Lanius, "Neural correlates of reexperiencing, avoidance, and dissociation in PTSD: Symptom dimensions and emotion dysregulation in responses to script-driven trauma imagery," J Trauma Stress, vol. 20, no. 5, 2007, doi:10.1002/jts.20284. 215. J.W. Hopper, P.A. Frewen, B.A. van der Kolk, and R.A. Lanius, "Neural correlates of reexperiencing, avoidance, and dissociation in PTSD: Symptom dimensions and emotion ⟦стр. 63⟧ dysregulation in responses to script-driven trauma imagery," J Trauma Stress, vol. 20, no. 5, 2007, doi:10.1002/jts.20284.
--- стр. 63 --- --- стр. 63 ---
@@ -1546,7 +1546,7 @@ Promote Recovery From Posttraumatic Stress?," Psychological Science in the Publi
Way, "Putting feelings into words: Affect labeling disrupts amygdala activity in response to affective stimuli: Research article," Psychol Sci, vol. 18, no. 5, 2007, doi:10.1111/j.14679280.2007.01916.x. Way, "Putting feelings into words: Affect labeling disrupts amygdala activity in response to affective stimuli: Research article," Psychol Sci, vol. 18, no. 5, 2007, doi:10.1111/j.14679280.2007.01916.x.
229. L. Iyadurai et al., "Preventing intrusive memories after trauma via a brief intervention involving Tetris computer game play in the emergency department: A proof-of-concept randomized controlled trial," Mol Psychiatry, vol. 23, no. 3, 2018, doi:10.1038/mp.2017.23. 229. L. Iyadurai et al., "Preventing intrusive memories after trauma via a brief intervention involving Tetris computer game play in the emergency department: A proof-of-concept randomized controlled trial," Mol Psychiatry, vol. 23, no. 3, 2018, doi:10.1038/mp.2017.23.
230. M. Gould, N. Greenberg, and J. Hetherton, "Stigma and the military: Evaluation of a 230. M. Gould, N. Greenberg, and J. Hetherton, "Stigma and the military: Evaluation of a
PTSD psychoeducational program," in Journal of Traumatic Stress, 2007, vol. 20, no. 4. doi:10.1002/jts.20233. PTSD psychoeducational program," in Journal of Traumatic Stress, 2007, vol. 20, no. 4. ⟦стр. 64⟧ doi:10.1002/jts.20233.
--- стр. 64 --- --- стр. 64 ---
@@ -1576,7 +1576,7 @@ Arch Gen Psychiatry, vol. 65, no. 6, 2008, doi: 10.1001/archpsyc.65.6.659.
245. C. Freyth, K. Elsesser, T. Lohrmann, and G. Sartory, "Effects of additional prolonged exposure to psychoeducation and relaxation in acute stress disorder," J Anxiety Disord, vol. 24, no. 245. C. Freyth, K. Elsesser, T. Lohrmann, and G. Sartory, "Effects of additional prolonged exposure to psychoeducation and relaxation in acute stress disorder," J Anxiety Disord, vol. 24, no.
8, 2010, doi: 10.1016/j.janxdis.2010.06.016. 8, 2010, doi: 10.1016/j.janxdis.2010.06.016.
246. R. D. V. Nixon, "Cognitive Processing Therapy Versus Supportive Counseling for Acute 246. R. D. V. Nixon, "Cognitive Processing Therapy Versus Supportive Counseling for Acute
Stress Disorder Following Assault: A Randomized Pilot Trial," Behav Ther, vol. 43, no. 4, 2012, doi: 10.1016/j.beth.2012.05.001. Stress Disorder Following Assault: A Randomized Pilot Trial," Behav Ther, vol. 43, no. 4, 2012, ⟦стр. 65⟧ doi: 10.1016/j.beth.2012.05.001.
--- стр. 65 --- --- стр. 65 ---
@@ -1647,7 +1647,7 @@ Department of the Army. July, 2006. 115 p.
Battlemind Debriefing and Battlemind Training as Early Interventions With Soldiers Returning Battlemind Debriefing and Battlemind Training as Early Interventions With Soldiers Returning
From Iraq: Randomization by Platoon. Journal of consulting and clinical psychology. 77. 92840.10.1037/a0016877. From Iraq: Randomization by Platoon. Journal of consulting and clinical psychology. 77. 92840.10.1037/a0016877.
278. Руководство для командиров по контролю над боевым стрессом (Полевой Устав 278. Руководство для командиров по контролю над боевым стрессом (Полевой Устав
Сухопутных войск США FM 22-51): сокр. пер. с англ. А.П. Нечаева//Зарубежная военная медицина (Информационный сборник). – N 96. – СПб, 1999. – С. 8-84. Сухопутных войск США FM 22-51): сокр. пер. с англ. А.П. Нечаева//Зарубежная военная ⟦стр. 67⟧ медицина (Информационный сборник). – N 96. – СПб, 1999. – С. 8-84.
--- стр. 67 --- --- стр. 67 ---
@@ -1915,7 +1915,7 @@ German-speaking countries. European Archives of Psychiatry and Clinical Neurosci
Информация для пациента Информация для пациента
Что такое посттравматическое стрессовое расстройство (ПТСР)? Что такое посттравматическое стрессовое расстройство (ПТСР)?
ПТСР – это расстройство, которое может развиться после перенесенного очень сильного стресса, который является тяжелым практически для любого человека и превышает нормальные возможности психики к адаптации. ПТСР – это расстройство, которое может развиться после перенесенного очень сильного стресса, который является тяжелым практически для любого человека и превышает нормальные возможности психики к адаптации.
К таким психотравмирующим воздействиям относят угрозу жизни, нападение, пытки, насилие, природные и техногенные катастрофы, военные действия, террористические акты, пребывание в плену или концентрационном лагере, свидетельство гибели другого человека, жизнеугрожающее заболевание, получение известия о неожиданной или насильственной смерти близкого человека и другие. К таким психотравмирующим воздействиям относят угрозу жизни, нападение, пытки, насилие, природные и техногенные катастрофы, военные действия, террористические акты, пребывание в плену или концентрационном лагере, свидетельство гибели другого человека, ⟦стр. 77⟧ жизнеугрожающее заболевание, получение известия о неожиданной или насильственной смерти близкого человека и другие.
--- стр. 77 --- --- стр. 77 ---
@@ -2016,7 +2016,7 @@ Scale – CAPS) разработана в двух вариантах (Weathers
Применение методики не только дает возможность оценить по пятибалльной шкале каждый симптом с точки зрения частоты и интенсивности проявления, но и определить достоверность полученной информации. Применение методики не только дает возможность оценить по пятибалльной шкале каждый симптом с точки зрения частоты и интенсивности проявления, но и определить достоверность полученной информации.
Шкала CAPS применяется, как правило, дополнительно к Структурированному клиническому интервью (СКИД) для клинической диагностики уровня выраженности симптоматики ПТСР и частоты ее проявления. Ее используют, если в ходе интервью диагностируется наличие каких-либо симптомов ПТСР или всего расстройства в целом Шкала CAPS применяется, как правило, дополнительно к Структурированному клиническому интервью (СКИД) для клинической диагностики уровня выраженности симптоматики ПТСР и частоты ее проявления. Ее используют, если в ходе интервью диагностируется наличие каких-либо симптомов ПТСР или всего расстройства в целом
(Weathers F.W., Litz B.T., 1994; Blake D.D., 1995). (Weathers F.W., Litz B.T., 1994; Blake D.D., 1995).
CAPS-1 позволяет оценить частоту встречаемости и интенсивность проявления индивидуальных симптомов расстройства, а также степень их влияния на социальную активность и производственную деятельность пациента. С помощью этой шкалы можно определить степень улучшения состояния при повторном исследовании по сравнению с предыдущим, валидность результатов и общую интенсивность симптомов. Если возможно, желательно использовать шкалу CAPS-1 в комплексе с прочими методами диагностики CAPS-1 позволяет оценить частоту встречаемости и интенсивность проявления индивидуальных симптомов расстройства, а также степень их влияния на социальную активность и производственную деятельность пациента. С помощью этой шкалы можно определить степень улучшения состояния при повторном исследовании по сравнению с предыдущим, валидность результатов и общую интенсивность симптомов. Если возможно, ⟦стр. 81⟧ желательно использовать шкалу CAPS-1 в комплексе с прочими методами диагностики
--- стр. 81 --- --- стр. 81 ---
@@ -2401,7 +2401,7 @@ Kudler, 1989);
| ОТМЕЧЕНО ЛИ 15 КАК ДА? | | НЕТ ДА ПТСР в настоящее время | | | | ОТМЕЧЕНО ЛИ 15 КАК ДА? | | НЕТ ДА ПТСР в настоящее время | | |
Ключ (интерпретация) Ключ (интерпретация)
Наличие утвердительных ответов на вопросы, направленные на диагностический критерий A, дают интервьюеру основание для дальнейшей беседы по следующим блокам вопросов, направленных на диагностические критерии B – травматическое событие настойчиво повторяется в переживании, C – постоянное избегание стимулов и блокировка эмоциональных реакций, связанных с травмой, D – постоянные симптомы возрастающего возбуждения, E – длительность протекания расстройства (более чем 1 месяц), F – расстройство вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности. Наличие утвердительных ответов на вопросы, направленные на диагностический ⟦стр. 93⟧ критерий A, дают интервьюеру основание для дальнейшей беседы по следующим блокам вопросов, направленных на диагностические критерии B – травматическое событие настойчиво повторяется в переживании, C – постоянное избегание стимулов и блокировка эмоциональных реакций, связанных с травмой, D – постоянные симптомы возрастающего возбуждения, E – длительность протекания расстройства (более чем 1 месяц), F – расстройство вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности.
--- стр. 93 --- --- стр. 93 ---
@@ -2425,7 +2425,7 @@ Kudler, 1989);
Обработка полученных данных ШОВТС проводится по ключу: субшкала "вторжение" (IN): суммируются баллы пунктов 1, 2, 3, 6, 9, 16, 20; – позволяет выявлять у испытуемого ночные кошмары, навязчивые мысли, связанные с травмирующим событием субшкала "избегание" (AV): суммируются баллы пунктов 5, 7, 8, 11, 12, 13, 17, 22; – позволяет выявлять у испытуемого симптомы избегания, включающие попытки смягчения или избегания переживаний, связанных с воздействием кризисного события субшкала "физиологическое возбуждение" (AR): суммируются баллы пунктов 4, 10, 14, Обработка полученных данных ШОВТС проводится по ключу: субшкала "вторжение" (IN): суммируются баллы пунктов 1, 2, 3, 6, 9, 16, 20; – позволяет выявлять у испытуемого ночные кошмары, навязчивые мысли, связанные с травмирующим событием субшкала "избегание" (AV): суммируются баллы пунктов 5, 7, 8, 11, 12, 13, 17, 22; – позволяет выявлять у испытуемого симптомы избегания, включающие попытки смягчения или избегания переживаний, связанных с воздействием кризисного события субшкала "физиологическое возбуждение" (AR): суммируются баллы пунктов 4, 10, 14,
15, 18, 19, 21; – позволяет выявлять у испытуемого злость и раздражительность, гипертрофированную реакцию испуга, психофизиологическое возбуждение, обусловленное воспоминаниями о событии, бессонницу. 15, 18, 19, 21; – позволяет выявлять у испытуемого злость и раздражительность, гипертрофированную реакцию испуга, психофизиологическое возбуждение, обусловленное воспоминаниями о событии, бессонницу.
Все пункты имеют прямое значение. Все пункты имеют прямое значение.
Опросник предназначен для изучения наличия, степени выраженности и симптоматической структуры посттравматического стрессового расстройства. Шкала зарекомендовала себя как надежный, валидный инструмент, прошедший кросс-культурную апробацию. Шкала может быть включена в батарею психодиагностических методик для работы с лицами, у которых имеется или подозревается ПТСР. Опросник предназначен для изучения наличия, степени выраженности и симптоматической структуры посттравматического стрессового расстройства. Шкала ⟦стр. 94⟧ зарекомендовала себя как надежный, валидный инструмент, прошедший кросс-культурную апробацию. Шкала может быть включена в батарею психодиагностических методик для работы с лицами, у которых имеется или подозревается ПТСР.
--- стр. 94 --- --- стр. 94 ---
@@ -2487,7 +2487,7 @@ Mississippi Scale) – клиническая тестовая методика,
Опросник состоит из 35 вопросов в "военном" и 39 вопросов в "гражданском" вариантах. 30 вопросов образуют три основные шкалы, которые соотносятся с тремя группами симптомов ПТСР согласно DSM-III: 11 вопросов первой шкалы описывают симптомы группы вторжения, 11 симптомов второй шкалы описывают симптомы группы избегания, 8 вопросов третьей шкалы описывают симптомы возбудимости. Пять оставшихся вопросов описывают симптомы, относящиеся к чувству вины и суицидальным наклонностям. Опросник состоит из 35 вопросов в "военном" и 39 вопросов в "гражданском" вариантах. 30 вопросов образуют три основные шкалы, которые соотносятся с тремя группами симптомов ПТСР согласно DSM-III: 11 вопросов первой шкалы описывают симптомы группы вторжения, 11 симптомов второй шкалы описывают симптомы группы избегания, 8 вопросов третьей шкалы описывают симптомы возбудимости. Пять оставшихся вопросов описывают симптомы, относящиеся к чувству вины и суицидальным наклонностям.
Популяционные исследования показали высокую внутреннюю согласованность военного (0,94) и гражданского (0,86) вариантов шкалы. Шкала обладает Популяционные исследования показали высокую внутреннюю согласованность военного (0,94) и гражданского (0,86) вариантов шкалы. Шкала обладает
87% чувствительностью и 63% специфичностью в диагностике ПТСР у контингента психически больных. 87% чувствительностью и 63% специфичностью в диагностике ПТСР у контингента психически больных.
Ответы на вопросы даются по шкале Ликкерта. Несмотря на группирование вопросов в четыре субшкалы, высчитывается только один итоговый коэффициент. Часть вопросов имеет перевернутые значения, что позволяет снизить установочность испытуемого или заполнение бланка в произвольном порядке. Ответы на вопросы даются по шкале Ликкерта. Несмотря на группирование вопросов в четыре субшкалы, высчитывается только один итоговый коэффициент. Часть вопросов имеет ⟦стр. 96⟧ перевернутые значения, что позволяет снизить установочность испытуемого или заполнение бланка в произвольном порядке.
--- стр. 96 --- --- стр. 96 ---
@@ -2561,7 +2561,7 @@ Mississippi Scale) – клиническая тестовая методика,
Семейное положение _____________ Детей ____________________ Семейное положение _____________ Детей ____________________
Профессия ____________________________________________________ Профессия ____________________________________________________
Вес ____________ Рост ____________ Вес ____________ Рост ____________
Ниже приводятся утверждения, обобщающие различный опыт людей, принимавших участие в боевых действиях. У каждого утверждения есть шкала от "1" до "5". Пользуясь данной шкалой, укажите, в какой степени вы согласны или не согласны с данным утверждением. Дайте только один ответ на каждое утверждение: Совершенно неверно, Ниже приводятся утверждения, обобщающие различный опыт людей, принимавших участие в боевых действиях. У каждого утверждения есть шкала от "1" до "5". Пользуясь данной шкалой, укажите, в какой степени вы согласны или не согласны с данным ⟦стр. 99⟧ утверждением. Дайте только один ответ на каждое утверждение: Совершенно неверно,
--- стр. 99 --- --- стр. 99 ---
@@ -2644,7 +2644,7 @@ Taylor, C.A. Mora)
- вопросник - вопросник
- другое (уточнить) - другое (уточнить)
Назначение: Назначение:
"Шкала оценки интенсивности боевого опыта" Т. Кина (Combat Exposure Scale) данная методика предназначена для оценки количественных параметров частоты и продолжительности участия военнослужащих в различных экстремальных ситуациях в условиях боевых действий. Для оценки влияния интенсивности боевого опыта на психологическое состояние участников военных конфликтов рекомендуется использовать эту методику совместно с другими методиками, такими как "Шкала для клинической диагностики ПТСР" (Clinical-administered PTSD Scale – CAPS, Weathers F.W. et al., 1992; "Шкала оценки интенсивности боевого опыта" Т. Кина (Combat Exposure Scale) данная методика предназначена для оценки количественных параметров частоты и ⟦стр. 102⟧ продолжительности участия военнослужащих в различных экстремальных ситуациях в условиях боевых действий. Для оценки влияния интенсивности боевого опыта на психологическое состояние участников военных конфликтов рекомендуется использовать эту методику совместно с другими методиками, такими как "Шкала для клинической диагностики ПТСР" (Clinical-administered PTSD Scale – CAPS, Weathers F.W. et al., 1992;
--- стр. 102 --- --- стр. 102 ---
@@ -2770,7 +2770,7 @@ Weathers F.W, 1993), "Миссисипская шкала для оценки п
L и др., 1990)], вопросы которой касались переживаний субъекта в то время, когда с ним происходило потенциально психотравмирующее событие. L и др., 1990)], вопросы которой касались переживаний субъекта в то время, когда с ним происходило потенциально психотравмирующее событие.
Обследуемым предлагается ответить на 9 вопросов, в которых описаны различные диссоциативные состояния, такие, например, как дереализация, деперсонализация, опыт выхода из тела и др. Ответы на вопросы даются в формате пятибалльной шкалы. Обследуемым предлагается ответить на 9 вопросов, в которых описаны различные диссоциативные состояния, такие, например, как дереализация, деперсонализация, опыт выхода из тела и др. Ответы на вопросы даются в формате пятибалльной шкалы.
Общий балл подсчитывается как среднее арифметическое по 9 пунктам. Общий балл подсчитывается как среднее арифметическое по 9 пунктам.
Диагностические критерии следующие: у здоровых показатели от 9 до 15 баллов; у пациентов с посттравматическими стрессовыми расстройствами – от 19 до 45 баллов. Диагностические критерии следующие: у здоровых показатели от 9 до 15 баллов; у ⟦стр. 106⟧ пациентов с посттравматическими стрессовыми расстройствами – от 19 до 45 баллов.
--- стр. 106 --- --- стр. 106 ---
@@ -2900,7 +2900,7 @@ F.W., 1986)
| 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% | | 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% |
|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|
12) ... испытывают чувство нереальности окружающих их людей и предметов, а может быть, и всего окружающего их мира. 12) ... испытывают чувство нереальности окружающих их людей и предметов, а может ⟦стр. 110⟧ быть, и всего окружающего их мира.
--- стр. 110 --- --- стр. 110 ---
@@ -2947,7 +2947,7 @@ F.W., 1986)
| 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% | | 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% |
|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|
21) ... находясь в одиночестве, иногда замечают, что громко разговаривают сами с собой. 21) ... находясь в одиночестве, иногда замечают, что громко разговаривают сами с ⟦стр. 111⟧ собой.
--- стр. 111 --- --- стр. 111 ---
+6
View File
@@ -59,6 +59,12 @@ retrieval:
"Приложение В": 1.5 "Приложение В": 1.5
"Приложение Г": 0.5 "Приложение Г": 0.5
"Критерии оценки качества": 0.5 "Критерии оценки качества": 0.5
# Заболевание в вопросе -> профильная КР (регулярные выражения по вопросу в нижнем регистре).
# Если названы оба — ни одна не получает преимущества. Иначе «ТМС при депрессии» отвечался цитатой из КР ПТСР.
doc_cues:
kr_depression: ["(?<!анти)депресс", "\\bдэ\\b", "\\bрдр\\b"]
kr_ptsd: ["птср", "посттравмат", "травм", "\\bвойн", "боев", "фронт", "ветеран", "\\bсво\\b", "ptsd"]
doc_cue_boost: 2.0
answer: answer:
max_chunks: 5 # в скольких лучших фрагментах ищем отрывок max_chunks: 5 # в скольких лучших фрагментах ищем отрывок
+1
View File
@@ -63,6 +63,7 @@ idle:
buttons: buttons:
checkin: "Как я сегодня" checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве" med_question: "Вопрос о лекарстве"
kb_question: "Вопрос о лечении"
contact_doctor: "Связаться с врачом" contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие" revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами. # Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
+51
View File
@@ -0,0 +1,51 @@
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
prompt: |-
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
question_starts:
- "что такое"
- "что значит"
- "что означает"
- "почему"
- "зачем"
- "как"
- "когда"
- "сколько"
- "можно ли"
- "нужно ли"
- "надо ли"
- "помогает ли"
- "поможет ли"
- "правда ли"
- "бывает ли"
- "что делать"
- "что будет"
- "чем"
- "где"
- "есть ли"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
small_talk:
- "как дела"
- "как ты"
- "как вы"
- "как жизнь"
- "как поживаете"
- "что нового"
- "кто ты"
- "ты кто"
- "ты бот"
- "ты робот"
- "привет"
- "спасибо"
+132
View File
@@ -0,0 +1,132 @@
"""Thin HTTP client for the dashboard. The frozen API schemas (`app/api/schemas.py`) are the only contract.
The dashboard never touches the databases: everything goes over HTTP to `API_BASE_URL`. Short timeouts keep the
UI responsive when the backend is down; every failure is turned into `ApiError` with a Russian message that the
page shows as a banner instead of a traceback.
"""
from __future__ import annotations
import logging
import os
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, TypeAdapter, ValidationError
from app.api.schemas import (
AckIn,
AlertOut,
ClockOut,
ConnectPatientIn,
ConnectPatientOut,
PatientCard,
PatientListItem,
)
log = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://localhost:8000"
DEFAULT_DOCTOR_ID = "doc-smirnova"
API_PREFIX = "/api/v1"
T = TypeVar("T")
class ApiError(Exception):
"""Any API failure, with a message safe to show to the doctor (no PII, no stack traces)."""
def __init__(self, message: str, status: int | None = None) -> None:
super().__init__(message)
self.message = message
self.status = status
class ApiUnavailable(ApiError):
"""Backend not reachable (connection refused, DNS, timeout)."""
class ApiClient:
def __init__(
self,
base_url: str | None = None,
doctor_id: str | None = None,
*,
timeout: float = 3.0,
transport: httpx.BaseTransport | None = None,
) -> None:
base = (base_url or os.environ.get("API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not base.endswith(API_PREFIX):
base += API_PREFIX
self.base_url = base
self.doctor_id = doctor_id or os.environ.get("DOCTOR_ID") or DEFAULT_DOCTOR_ID
self._http = httpx.Client(
base_url=base, timeout=httpx.Timeout(timeout, connect=min(timeout, 1.5)), transport=transport
)
# ------------------------------------------------------------------ doctor endpoints
def patients(self) -> list[PatientListItem]:
return self._get(f"/doctors/{self.doctor_id}/patients", list[PatientListItem])
def patient_card(self, token: str) -> PatientCard:
return self._get(f"/patients/{token}", PatientCard, params={"doctor_id": self.doctor_id})
def alerts(self, since_id: int = -1, *, open_only: bool = False) -> list[AlertOut]:
params = {"since_id": since_id, "open_only": str(open_only).lower()}
return self._get(f"/doctors/{self.doctor_id}/alerts", list[AlertOut], params=params)
def ack(self, alert_id: int) -> AlertOut:
return self._request("POST", f"/alerts/{alert_id}/ack", AlertOut, json=AckIn(doctor_id=self.doctor_id))
def connect(self, body: ConnectPatientIn) -> ConnectPatientOut:
return self._request("POST", "/patients/connect", ConnectPatientOut, json=body)
def clock(self) -> ClockOut | None:
"""Demo clock (ADR-005); optional decoration, so failures return None instead of raising."""
try:
return self._get("/demo/clock", ClockOut)
except ApiError:
return None
# ------------------------------------------------------------------ plumbing
def _get(self, path: str, model: type[T], params: dict[str, Any] | None = None) -> T:
return self._request("GET", path, model, params=params)
def _request(
self,
method: str,
path: str,
model: type[T],
*,
params: dict[str, Any] | None = None,
json: BaseModel | None = None,
) -> T:
payload = json.model_dump(mode="json") if json is not None else None
try:
resp = self._http.request(method, path, params=params, json=payload)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
log.warning("api unavailable: %s %s (%s)", method, _route(path), type(exc).__name__)
raise ApiUnavailable("API недоступен — проверьте, что сервис запущен") from exc
except httpx.HTTPError as exc:
log.warning("api transport error: %s %s (%s)", method, _route(path), type(exc).__name__)
raise ApiUnavailable("API недоступен — ошибка соединения") from exc
if resp.status_code == 404:
raise ApiError("Не найдено", 404)
if resp.status_code >= 400:
log.warning("api error: %s %s -> %s", method, _route(path), resp.status_code)
raise ApiError(f"Ошибка API ({resp.status_code})", resp.status_code)
try:
return TypeAdapter(model).validate_python(resp.json())
except (ValueError, ValidationError) as exc:
log.warning("api bad payload: %s %s", method, _route(path))
raise ApiError("API вернул неожиданный ответ") from exc
def close(self) -> None:
self._http.close()
def _route(path: str) -> str:
"""Path without identifiers, so logs carry no patient tokens."""
parts = path.strip("/").split("/")
return "/" + "/".join(p if not any(ch.isdigit() for ch in p) and not p.startswith("tok") else "*" for p in parts)
+118 -13
View File
@@ -1,18 +1,123 @@
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.""" """Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
import os Entry point: `streamlit run dashboard/app.py`. Pages are discovered from `dashboard/pages/*.py` — adding a page
is adding a file, app.py never needs editing:
import httpx * file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
import streamlit as st spaces (`2_Демо-консоль.py` → «Демо-консоль»);
* section: pages with N == 1 go to «Кабинет врача», the rest to «Демо»;
* optional module-level constants (read as text, the file is not imported):
`NAV_TITLE = "..."`, `NAV_ICON = ":material/terminal:"`, `NAV_SECTION = "..."`, `NAV_URL = "slug"`.
"""
API = os.environ.get("API_BASE_URL", "http://localhost:8000") from __future__ import annotations
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide") import re
st.title("Кабинет врача") import sys
st.caption("Прототип на синтетических данных") from dataclasses import dataclass
from pathlib import Path
try: ROOT = Path(__file__).resolve().parents[1]
status = httpx.get(f"{API}/health", timeout=2).json().get("status") _HERE = str(Path(__file__).resolve().parent)
except httpx.HTTPError: # `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
status = "недоступен" # (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
st.write(f"API: **{status}**") sys.path[:] = [p for p in sys.path if p not in ("", _HERE) and Path(p or ".").resolve() != Path(_HERE)]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
PAGES_DIR = Path(__file__).resolve().parent / "pages"
DOCTOR_SECTION = "Кабинет врача"
DEMO_SECTION = "Демо"
DEFAULT_ICONS = {
"Пациенты": ":material/groups:",
"Подключить пациента": ":material/person_add:",
"Демо-консоль": ":material/smartphone:",
"Сплит-экран": ":material/splitscreen:",
}
_NAME_RE = re.compile(r"^(\d+)([a-z]?)_(.+)$")
_META_RE = re.compile(r"""^NAV_(TITLE|ICON|SECTION|URL)\s*(?::\s*str\s*)?=\s*["'](.+?)["']\s*$""", re.MULTILINE)
_TRANSLIT = dict(
zip(
"абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
[*"abvgdee", "zh", *"zijklmnoprstufh", "ts", "ch", "sh", "sch", "_", "y", "_", "e", "yu", "ya"],
strict=True,
)
)
@dataclass(frozen=True)
class PageSpec:
path: Path
title: str
icon: str
section: str
url_path: str
order: tuple[int, str]
def slugify(title: str) -> str:
out = "".join(_TRANSLIT.get(ch, ch) for ch in title.lower())
out = out.replace("_", "")
return re.sub(r"[^a-z0-9]+", "-", out).strip("-") or "page"
def discover_pages(pages_dir: Path = PAGES_DIR) -> list[PageSpec]:
specs = []
for path in pages_dir.glob("*.py"):
m = _NAME_RE.match(path.stem)
if not m:
continue
number, letter, raw = int(m.group(1)), m.group(2), m.group(3)
meta = dict(_META_RE.findall(path.read_text(encoding="utf-8")))
title = meta.get("TITLE", raw.replace("_", " "))
specs.append(
PageSpec(
path=path,
title=title,
icon=meta.get("ICON", DEFAULT_ICONS.get(title, ":material/description:")),
section=meta.get("SECTION", DOCTOR_SECTION if number == 1 else DEMO_SECTION),
url_path=meta.get("URL", slugify(title)),
order=(number, letter),
)
)
return sorted(specs, key=lambda s: s.order)
def main() -> None:
import streamlit as st
from dashboard import theme
from dashboard.components import live_alerts, state, ui
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
st.html(theme.CSS)
st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large")
specs = discover_pages()
nav: dict[str, list[st.Page]] = {}
section_of: dict[str, str] = {}
for i, s in enumerate(specs):
page = st.Page(str(s.path), title=s.title, icon=s.icon, url_path=s.url_path, default=i == 0)
nav.setdefault(s.section, []).append(page)
section_of[s.url_path] = s.section
current = st.navigation(nav, position="sidebar", expanded=True)
with st.sidebar:
st.html(
'<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
"Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
)
st.caption("Прототип на синтетических данных")
short, _ = state.doctor_names()
st.html(ui.workspace_header(short, current.title))
live_alerts.flush_pending_toasts()
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario.
live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
current.run()
ui.footer()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="228" height="48" viewBox="0 0 228 48" role="img" aria-label="PTSRaiAgent — помощник врача">
<rect x="0" y="6" width="36" height="36" rx="11" fill="#285e4e"/>
<path d="M18 15v18M9 24h18" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<g font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif">
<text x="47" y="25" fill="#213b38" font-size="21" font-weight="600" letter-spacing="-.7">PTSRaiAgent</text>
<text x="48" y="39" fill="#63736f" font-size="9" letter-spacing="1">ПОМОЩНИК ВРАЧА</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+173
View File
@@ -0,0 +1,173 @@
"""Plotly figures of the patient card. Pure builders: data in, `go.Figure` out (tested without Streamlit).
Chart rules: one y-axis per chart (scales of different range are separate charts, daily metrics are small
multiples on a shared time axis), a single neutral series colour, status colours only for thresholds.
"""
from __future__ import annotations
from collections.abc import Iterable
from datetime import date, timedelta
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from app.api.schemas import SeriesPoint
from dashboard import theme
from dashboard.components.helpers import SCALE_MAX, metric_points, severity_bands, short_date
# Band shading: one warm hue with growing opacity (sequential), so "higher = worse" reads without a legend.
_BAND_ALPHA = [0.0, 0.05, 0.09, 0.14, 0.20]
def _day_ticks(days: list[date], max_ticks: int = 10) -> dict[str, object]:
if not days:
return {}
first, last = min(days), max(days)
span = (last - first).days + 1
step = max(1, -(-span // max_ticks))
ticks = [first + timedelta(days=i) for i in range(0, span, step)]
return {"tickmode": "array", "tickvals": ticks, "ticktext": [short_date(d) for d in ticks]}
def _point_label(p: SeriesPoint) -> str:
return f"{p.value:g}" if p.scheduled else f"{p.value:g} · внепл."
def _therapy_day(d: date, start: date | None) -> str:
return f"день {(d - start).days}" if start else short_date(d)
def scale_figure(
series: Iterable[SeriesPoint], scale_id: str, title: str, start: date | None = None
) -> go.Figure | None:
"""Total score over time with the severity gradations from config/scales; unscheduled sessions are hollow."""
pts = metric_points(series, scale_id)
if not pts:
return None
top = SCALE_MAX.get(scale_id, max(p.value for p in pts))
fig = go.Figure()
bands = severity_bands(scale_id)
for i, b in enumerate(bands):
alpha = _BAND_ALPHA[min(i, len(_BAND_ALPHA) - 1)] if len(bands) > 3 else [0.0, 0.08, 0.18][min(i, 2)]
fig.add_hrect(y0=b.lo, y1=b.hi + 1 if b.hi < top else top, line_width=0, fillcolor=f"rgba(214,69,69,{alpha})")
fig.add_annotation(
x=0,
xref="paper",
xanchor="left",
y=(b.lo + min(b.hi + 1, top)) / 2,
text=b.label,
showarrow=False,
font={"size": 10, "color": "rgba(122,130,140,0.85)"},
)
xs = [p.day for p in pts]
hover = [f"{short_date(p.day)} · {_therapy_day(p.day, start)}" for p in pts]
fig.add_trace(
go.Scatter(
x=xs,
y=[p.value for p in pts],
mode="lines+markers+text",
line={"color": theme.SERIES, "width": 2},
marker={
"size": [9 if p.scheduled else 13 for p in pts],
"symbol": ["circle" if p.scheduled else "diamond-open" for p in pts],
"color": theme.SERIES,
"line": {"width": [2 if p.scheduled else 2.5 for p in pts], "color": "#FFFFFF"},
},
text=[_point_label(p) if i == len(pts) - 1 else "" for i, p in enumerate(pts)],
textposition="top left",
textfont={"color": theme.INK, "size": 12},
customdata=[[h, "плановый" if p.scheduled else "внеплановый"] for h, p in zip(hover, pts, strict=True)],
hovertemplate="%{customdata[0]}<br><b>%{y:g}</b> баллов · %{customdata[1]}<extra></extra>",
name=title,
)
)
pad = timedelta(days=1)
fig.update_layout(
theme.plotly_layout(title={"text": title, "font": {"size": 14, "color": theme.INK}, "x": 0}, height=260),
hovermode="closest",
)
fig.update_xaxes(**theme.AXIS, **_day_ticks(xs, 4), tickangle=0, range=[min(xs) - pad, max(xs) + pad])
fig.update_yaxes(**theme.AXIS, range=[0, top], fixedrange=True)
return fig
def wellbeing_figure(series: Iterable[SeriesPoint], start: date | None = None) -> go.Figure | None:
"""Daily check-in as three small multiples on one time axis: mood 0–10, sleep hours, alcohol 0/1/2."""
data = list(series)
mood, sleep, alcohol = (metric_points(data, m) for m in ("mood", "sleep_hours", "alcohol"))
if not (mood or sleep or alcohol):
return None
days = sorted({p.day for p in mood + sleep + alcohol})
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
vertical_spacing=0.09,
row_heights=[0.42, 0.33, 0.25],
subplot_titles=("Настроение, 0–10", "Сон, часов", "Алкоголь"),
)
red_line = {"color": theme.TONES["red"].solid, "width": 1, "dash": "dot"}
def hov(p: SeriesPoint) -> str:
return f"{short_date(p.day)} · {_therapy_day(p.day, start)}"
if mood:
# Mood ≤ 3 for three days triggers an unscheduled PHQ-9 (config/rules.yaml) — shade that zone.
fig.add_hrect(y0=0, y1=3, line_width=0, fillcolor="rgba(214,69,69,0.08)", row=1, col=1)
fig.add_trace(
go.Scatter(
x=[p.day for p in mood],
y=[p.value for p in mood],
mode="lines+markers",
line={"color": theme.SERIES, "width": 2},
marker={"size": 8, "color": theme.SERIES, "line": {"width": 2, "color": "#FFFFFF"}},
customdata=[hov(p) for p in mood],
hovertemplate="%{customdata}<br>настроение <b>%{y:g}</b>/10<extra></extra>",
),
row=1,
col=1,
)
if sleep:
fig.add_trace(
go.Bar(
x=[p.day for p in sleep],
y=[p.value for p in sleep],
marker={"color": [theme.TONES["red"].solid if p.value < 4 else "#9DBFEA" for p in sleep]},
marker_cornerradius=4,
customdata=[hov(p) for p in sleep],
hovertemplate="%{customdata}<br>сон ≈ <b>%{y:g}</b> ч<extra></extra>",
),
row=2,
col=1,
)
fig.add_hline(y=4, line=red_line, row=2, col=1)
if alcohol:
alc_label = {0: "нет", 1: "1–2 порции", 2: "больше"}
fig.add_trace(
go.Bar(
x=[p.day for p in alcohol],
y=[max(p.value, 0.08) for p in alcohol],
marker={
"color": [
{0: theme.LINE, 1: theme.TONES["yellow"].solid}.get(int(p.value), theme.TONES["red"].solid)
for p in alcohol
]
},
marker_cornerradius=4,
customdata=[[hov(p), alc_label.get(int(p.value), "?")] for p in alcohol],
hovertemplate="%{customdata[0]}<br>алкоголь: <b>%{customdata[1]}</b><extra></extra>",
),
row=3,
col=1,
)
fig.update_layout(theme.plotly_layout(height=430, bargap=0.25, hovermode="x"))
fig.update_annotations(font={"size": 12, "color": theme.INK_2}, x=0, xanchor="left")
pad = timedelta(hours=14)
fig.update_xaxes(**theme.AXIS, range=[days[0] - pad, days[-1] + pad])
fig.update_xaxes(**_day_ticks(days), row=3, col=1)
fig.update_yaxes(**theme.AXIS, fixedrange=True)
fig.update_yaxes(range=[0, 10.5], tickvals=[0, 3, 5, 10], row=1, col=1)
fig.update_yaxes(range=[0, 9], tickvals=[0, 4, 8], row=2, col=1)
fig.update_yaxes(range=[0, 2.2], tickvals=[0, 1, 2], ticktext=["0", "1–2", "3+"], row=3, col=1)
return fig
+299
View File
@@ -0,0 +1,299 @@
"""Pure presentation logic of the dashboard (no Streamlit calls), unit-tested in tests/test_dashboard_helpers.py.
Clinical meaning stays on the backend: colours come from ADR-006 levels computed by the rules engine and the
discontinuation-risk index is computed server-side. Here we only order, label and reshape that data for display.
"""
from __future__ import annotations
import random
import re
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import date, datetime
from functools import cache
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, PatientListItem, SeriesPoint
TZ = ZoneInfo("Europe/Moscow")
REPO_ROOT = Path(__file__).resolve().parents[2]
# ADR-006 ordering for the doctor's attention: acute first, then high/moderate, then info-only, then quiet.
STATUS_ORDER = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
MODULE_LABELS = {"veteran": "ветеран", "depression": "депрессия", "family": "семья"}
LEVEL_LABELS = {"acute": "Кризис", "high": "Высокий", "moderate": "Умеренный", "info": "Инфо"}
MONTHS_SHORT = ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]
DRUGS: dict[str, list[str]] = {
"сертралин": ["25 мг", "50 мг", "100 мг", "150 мг"],
"пароксетин": ["10 мг", "20 мг", "30 мг"],
"эсциталопрам": ["5 мг", "10 мг", "20 мг"],
"флуоксетин": ["20 мг", "40 мг"],
}
# ------------------------------------------------------------------ list: ordering, filtering, KPIs
def sort_patients(items: Iterable[PatientListItem]) -> list[PatientListItem]:
"""red → yellow → grey → green; inside a colour the higher discontinuation risk goes first."""
return sorted(items, key=lambda p: (STATUS_ORDER[p.status_color], -p.discontinuation_risk.score, p.display_name))
def filter_patients(
items: Iterable[PatientListItem], color: str | None = None, query: str = ""
) -> list[PatientListItem]:
q = query.strip().lower()
return [
p
for p in items
if (color is None or p.status_color == color)
and (not q or q in p.display_name.lower() or q in (p.drug or "").lower())
]
@dataclass(frozen=True)
class Kpis:
total: int
red: int
yellow: int
grey: int
green: int
high_risk: int
def kpis(items: Sequence[PatientListItem]) -> Kpis:
by = {c: sum(1 for p in items if p.status_color == c) for c in STATUS_ORDER}
high = sum(1 for p in items if p.discontinuation_risk.level == "high")
return Kpis(len(items), by["red"], by["yellow"], by["grey"], by["green"], high)
# ------------------------------------------------------------------ small visual mappings
@dataclass(frozen=True)
class View:
text: str
tone: str # key of theme.TONES
hint: str = ""
def trend_view(trend: str) -> View:
"""Direction of symptom scores; "up" means symptoms got worse (schemas.PatientListItem.trend)."""
return {
"up": View("↑", "red", "симптомы нарастают"),
"down": View("↓", "green", "симптомы снижаются"),
"flat": View("→", "grey", "без динамики"),
}.get(trend, View("·", "grey", "мало данных"))
def risk_view(risk: DiscontinuationRisk) -> View:
"""Badge for the rule-based discontinuation-risk index (score 0–100) with factors as a tooltip."""
label, tone = {"high": ("Высокий", "red"), "medium": ("Средний", "yellow"), "low": ("Низкий", "green")}[risk.level]
factors = "\n".join(f"• {f}" for f in risk.factors) or "• значимых факторов нет"
return View(f"{label} · {risk.score}", tone, f"Риск прерывания терапии: {risk.score}/100\n{factors}")
def adherence_view(adherence: float | None) -> View:
"""7-day adherence. ≥ 80 % is the usual "adherent" cut-off for antidepressants; < 60 % needs a call."""
if adherence is None:
return View("—", "grey")
pct = round(adherence * 100)
tone = "green" if adherence >= 0.8 else "yellow" if adherence >= 0.6 else "red"
return View(f"{pct}%", tone)
def status_view(color: str, top_level: str | None = None) -> View:
if color == "red":
return View("Срочно", "red", "Кризисное уведомление")
if color == "yellow":
return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту")
if color == "grey":
return View("Нет ответа", "grey", "молчание / только инфо-флаги")
return View("Без уведомлений", "green", "открытых сигналов нет")
def level_tone(level: str) -> str:
return {"acute": "red", "high": "yellow", "moderate": "yellow"}.get(level, "grey")
# ------------------------------------------------------------------ scales
@dataclass(frozen=True)
class Band:
lo: float
hi: float
code: str
label: str
SCALE_MAX = {"phq9": 27, "pcl5": 80}
@cache
def severity_bands(scale_id: str) -> tuple[Band, ...]:
"""Severity gradations from config/scales/<id>.yaml (ADR-008: thresholds live in config, not code)."""
path = REPO_ROOT / "config" / "scales" / f"{scale_id}.yaml"
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except OSError:
return ()
rows = data.get("severity", [])
return tuple(Band(float(b["min"]), float(b["max"]), str(b["code"]), str(b["label"])) for b in rows)
def severity_label(scale_id: str, value: float | None) -> str:
if value is None:
return ""
for b in severity_bands(scale_id):
if b.lo <= value <= b.hi:
return b.label
return ""
def metric_points(series: Iterable[SeriesPoint], metric: str) -> list[SeriesPoint]:
return sorted((p for p in series if p.metric == metric), key=lambda p: p.day)
def last_and_prev(series: Iterable[SeriesPoint], metric: str) -> tuple[float | None, float | None]:
pts = metric_points(series, metric)
last = pts[-1].value if pts else None
prev = pts[-2].value if len(pts) > 1 else None
return last, prev
# ------------------------------------------------------------------ dose calendar
@dataclass
class DoseGrid:
"""Heat-strip of doses: one column per calendar day, one row per scheduled time of day."""
days: list[date] = field(default_factory=list)
slots: list[str] = field(default_factory=list)
cells: dict[tuple[str, date], str] = field(default_factory=dict)
counts: dict[str, int] = field(default_factory=dict)
@property
def adherence(self) -> float | None:
"""Share of resolved doses taken (pending doses are not counted yet)."""
resolved = sum(self.counts.get(s, 0) for s in ("taken", "missed", "skipped"))
return self.counts.get("taken", 0) / resolved if resolved else None
def _local(dt: datetime) -> datetime:
return dt.astimezone(TZ) if dt.tzinfo else dt
def dose_grid(doses: Iterable[DoseOut]) -> DoseGrid:
grid = DoseGrid(counts={"taken": 0, "missed": 0, "skipped": 0, "pending": 0})
items = sorted(doses, key=lambda d: d.planned_at)
if not items:
return grid
days: set[date] = set()
slots: set[str] = set()
for d in items:
local = _local(d.planned_at)
day, slot = local.date(), local.strftime("%H:%M")
days.add(day)
slots.add(slot)
grid.cells[(slot, day)] = d.status
grid.counts[d.status] += 1
first, last = min(days), max(days)
grid.days = [date.fromordinal(o) for o in range(first.toordinal(), last.toordinal() + 1)]
grid.slots = sorted(slots)
return grid
# ------------------------------------------------------------------ alerts
def apply_local_acks(alerts: Iterable[AlertOut], acks: dict[int, tuple[str, datetime]]) -> list[AlertOut]:
"""Overlay acks done in this session (so the UI reflects the click even before the next poll)."""
out = []
for a in alerts:
if a.acked_at is None and a.id in acks:
by, at = acks[a.id]
a = a.model_copy(update={"acked_by": by, "acked_at": at})
out.append(a)
return out
def split_new(alerts: Iterable[AlertOut], seen: set[int]) -> list[AlertOut]:
return sorted((a for a in alerts if a.id not in seen), key=lambda a: a.id)
def sort_alerts(alerts: Iterable[AlertOut]) -> list[AlertOut]:
"""Open first (acute before others), then newest first."""
rank = {"acute": 0, "high": 1, "moderate": 2, "info": 3}
return sorted(alerts, key=lambda a: (a.acked_at is not None, rank.get(a.level, 9), -a.ts.timestamp()))
# ------------------------------------------------------------------ Russian formatting
def plural_ru(n: int, one: str, few: str, many: str) -> str:
n = abs(n)
if n % 10 == 1 and n % 100 != 11:
return one
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
return few
return many
def age_label(age: int) -> str:
if age <= 0:
return "возраст не указан"
return f"{age} {plural_ru(age, 'год', 'года', 'лет')}"
def short_date(d: date) -> str:
return f"{d.day} {MONTHS_SHORT[d.month - 1]}"
def when_label(dt: datetime, today: date | None = None) -> str:
local = _local(dt)
day = "сегодня" if today and local.date() == today else short_date(local.date())
return f"{day}, {local:%H:%M}"
def visit_label(visit: date | None, today: date | None) -> str:
if visit is None:
return "визит не назначен"
if today is None:
return f"визит {short_date(visit)}"
delta = (visit - today).days
if delta == 0:
return "визит сегодня"
if delta < 0:
return f"визит {short_date(visit)} (просрочен)"
return f"визит {short_date(visit)} · через {delta} {plural_ru(delta, 'день', 'дня', 'дней')}"
# ------------------------------------------------------------------ connect form
def absolute_link(link: str, public_url: str) -> str:
"""The API may return a relative web-console link (`/console?code=…`); a QR needs an absolute URL."""
if link.startswith(("http://", "https://", "tg://")):
return link
return public_url.rstrip("/") + "/" + link.lstrip("/")
def snils_checksum(digits: str) -> int:
"""Control number of a SNILS (Pension Fund algorithm): Σ digit·(9−i), then mod 101, 100/101 → 0."""
s = sum(int(ch) * (9 - i) for i, ch in enumerate(digits))
if s < 100:
return s
if s in (100, 101):
return 0
s %= 101
return 0 if s == 100 else s
def format_snils(digits: str, check: int) -> str:
return f"{digits[0:3]}-{digits[3:6]}-{digits[6:9]} {check:02d}"
def is_valid_snils(value: str) -> bool:
d = re.sub(r"\D", "", value)
return len(d) == 11 and snils_checksum(d[:9]) == int(d[9:])
def synthetic_snils(rng: random.Random | None = None) -> str:
"""Random checksum-valid SNILS for synthetic test patients. Starts with 000 so it never looks like a real one."""
r = rng or random.Random()
digits = "000" + "".join(str(r.randint(0, 9)) for _ in range(6))
return format_snils(digits, snils_checksum(digits))
+85
View File
@@ -0,0 +1,85 @@
"""Live alerts across all pages: polls the API every few seconds, toasts new alerts, pins open acute ones on top.
Uses the built-in `st.fragment(run_every=...)`: only this fragment re-runs on the timer, the page is untouched
unless a new alert arrives on a doctor page (then the whole app reruns so the list and the card pick it up).
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import AlertOut
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import helpers as h
from dashboard.components import state, ui
POLL_SECONDS = 2.5
MAX_BANNER = 3
def _toast(a: AlertOut) -> None:
icon = theme.TONES[h.level_tone(a.level)].icon
st.toast(f"**{a.patient_name}** — {a.reason}", icon=icon, duration="long" if a.level == "acute" else "short")
def flush_pending_toasts() -> None:
"""Toasts queued right before a full-app rerun are shown on the next run (a rerun drops pending deltas)."""
for a in st.session_state.pop("pending_toasts", []):
_toast(a)
def _banner(acute: list[AlertOut]) -> None:
for a in acute[:MAX_BANNER]:
left, right = st.columns([8, 1.6], vertical_alignment="center")
with left:
st.html(
ui.banner(
f"<b>Кризисный сигнал · {escape(a.patient_name)}</b> — {escape(a.reason)}"
f'<div style="font-size:.8rem;margin-top:2px">{escape(h.when_label(a.ts))} · '
"по протоколу — связаться с пациентом сегодня</div>",
tone="red",
pulse=True,
)
)
with right:
if st.button(
"Карточка", key=f"acute-open-{a.id}", type="primary", icon=":material/arrow_forward:", width="stretch"
):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": a.token})
if len(acute) > MAX_BANNER:
st.caption(f"Ещё кризисных сигналов: {len(acute) - MAX_BANNER}")
def render(*, rerun_app_on_new: bool = True) -> None:
"""Place once per run (app.py does it above every page)."""
@st.fragment(run_every=POLL_SECONDS)
def _poll() -> None:
ss = st.session_state
try:
alerts = state.client().alerts(open_only=True)
except ApiError:
return # pages show their own "API недоступен" banner; the poller just retries next tick
acked = state.local_acks()
alerts = [a for a in alerts if a.acked_at is None and a.id not in acked]
seen: set[int] | None = ss.get("seen_alert_ids")
if seen is None:
# First poll of the session: what is already open is not "new" — no toast storm on page load.
ss["seen_alert_ids"] = {a.id for a in alerts}
new: list[AlertOut] = []
else:
new = h.split_new(alerts, seen)
seen.update(a.id for a in new)
if new and rerun_app_on_new:
ss["pending_toasts"] = ss.get("pending_toasts", []) + new
st.rerun(scope="app")
for a in new:
_toast(a)
_banner(h.sort_alerts(a for a in alerts if a.level == "acute"))
_poll()
+212
View File
@@ -0,0 +1,212 @@
"""Patient card: header, key numbers, charts, dose calendar, alerts feed with ack, safety plan, AI summary."""
from __future__ import annotations
from datetime import date
from html import escape
import streamlit as st
from app.api.schemas import AlertOut, PatientCard
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import charts, state, ui
from dashboard.components import helpers as h
def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
"""Heat-strip: columns = days, rows = scheduled times. Glyphs duplicate colour for colour-blind readers."""
if not grid.days:
return '<div class="mt-dim">Данных о приёме пока нет</div>'
glyph = {"taken": "✓", "missed": "✕", "skipped": "–", "pending": "·"}
label = {"taken": "принял", "missed": "нет ответа", "skipped": "пропустил", "pending": "ожидается"}
cols = f"56px repeat({len(grid.days)}, minmax(18px, 1fr))"
cells = ['<div class="lab"></div>']
cells += [f'<div class="lab">{d.day}</div>' for d in grid.days]
for slot in grid.slots:
cells.append(f'<div class="lab" style="text-align:left">{slot}</div>')
for d in grid.days:
status = grid.cells.get((slot, d))
if status is None:
cells.append('<div class="cell c-none"></div>')
continue
day = f" · день {(d - start).days}" if start else ""
tip = f"{h.short_date(d)}{day}, {slot}: {label[status]}"
cells.append(f'<div class="cell c-{status}" title="{escape(tip)}">{glyph[status]}</div>')
c = grid.counts
legend = (
'<div class="mt-legend">'
f'<span><i style="background:var(--green)"></i>принял · {c["taken"]}</span>'
f'<span><i style="background:var(--yellow)"></i>пропустил · {c["skipped"]}</span>'
f'<span><i style="background:var(--red)"></i>нет ответа · {c["missed"]}</span>'
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>"
)
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}'
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
return (
f'<div class="mt-kpi" style="--kpi-accent:{theme.TONES[tone].solid}"><div class="lbl">{escape(label)}</div>'
f'<div class="val">{value}</div><div class="hint">{escape(hint)}</div></div>'
)
def _score_tile(card: PatientCard, scale_id: str, title: str) -> str:
last, prev = h.last_and_prev(card.series, scale_id)
if last is None:
return _tile(title, '<span class="mt-dim">—</span>', "не проводился", "grey")
delta = ""
if prev is not None and last != prev:
arrow = "↑" if last > prev else "↓"
tone = "red" if last > prev else "green"
delta = f' <span style="font-size:1rem;color:{theme.TONES[tone].ink}">{arrow} с {prev:g}</span>'
sev = h.severity_label(scale_id, last)
return _tile(title, f"{last:g}{delta}", sev, "red" if last > (prev or last) else "blue")
def _header(card: PatientCard, today: date | None) -> None:
p = card.patient
status = h.status_view(p.status_color)
risk = h.risk_view(p.discontinuation_risk)
chips = "".join(ui.chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
meta = (
f"{h.age_label(p.age)} · {chips} · {escape(p.drug or 'без назначения')} · день терапии {p.therapy_day}"
f" · {h.visit_label(p.next_visit, today)}"
)
st.html(
'<div class="mt-head"><div>'
f'<div style="margin-bottom:6px">{ui.badge(status.text, status.tone, status.hint, icon="dot")}</div>'
f'<h1>{escape(p.display_name)}</h1><div class="mt-sub">{meta}</div></div>'
f'<div class="mt-side"><div class="mt-kicker" style="margin-bottom:4px">Риск прерывания терапии</div>'
f"{ui.badge(risk.text, risk.tone, risk.hint)}"
f'<div class="mt-meta">{escape(", ".join(p.discontinuation_risk.factors))}</div>'
"</div></div>"
)
def _ack(alert: AlertOut) -> None:
try:
res = state.client().ack(alert.id)
except ApiError as exc:
st.toast(f"Не удалось подтвердить: {exc.message}", icon="⚠️")
return
state.local_acks()[alert.id] = (res.acked_by or state.client().doctor_id, res.acked_at or state.now() or alert.ts)
st.toast(f"Алерт подтверждён: {alert.reason}", icon="✅")
def _alerts_feed(alerts: list[AlertOut], today: date | None) -> None:
st.html('<div class="mt-panel-title">Алерты</div>')
if not alerts:
st.caption("Сигналов нет")
return
for a in h.sort_alerts(alerts):
tone = h.level_tone(a.level)
acked = a.acked_at is not None
level = h.LEVEL_LABELS.get(a.level, a.level)
status = f"подтверждён {h.when_label(a.acked_at, today)}" if a.acked_at else "открыт"
st.html(
f'<div class="mt-alert t-{tone}{" acked" if acked else ""}">'
f'<div class="when">{theme.TONES[tone].icon} {level} · {escape(h.when_label(a.ts, today))}</div>'
f'<div class="why">{escape(a.reason)}</div>'
f'<div class="when">{escape(status)}</div></div>'
)
if not acked:
st.button(
"Подтвердить",
key=f"ack-{a.id}",
on_click=_ack,
args=(a,),
icon=":material/check:",
type="primary" if a.level == "acute" else "secondary",
help="Врач видел сигнал и принял меры (запись в журнал аудита)",
)
def _summary(text: str) -> None:
st.html('<div class="mt-panel-title">Саммари (ИИ, по структурированным данным)</div>')
body = escape(text) if text else '<span class="mt-dim">Саммари ещё не сформировано</span>'
st.html(
f'<div class="mt-summary">{body}<div class="disc">Сформировано по структурированным данным '
"(шкалы, чек-ины, приём, алерты) без свободного текста пациента. Решения принимает врач.</div></div>"
)
def _safety_plan(plan: list[str] | None) -> None:
st.html('<div class="mt-panel-title">План безопасности</div>')
if not plan:
st.caption("Не составлен. Составляется с пациентом при острых сигналах (Stanley–Brown).")
return
items = "".join(f"<li>{escape(step)}</li>" for step in plan)
st.html(f'<div class="mt-plan"><ol>{items}</ol></div>')
def _prescriptions(card: PatientCard) -> None:
st.html('<div class="mt-panel-title">Назначения</div>')
if not card.prescriptions:
st.caption(card.patient.drug or "Нет активных назначений")
return
for rx in card.prescriptions:
times = ", ".join(rx.schedule.get("times", []))
since = f" · с {h.short_date(rx.start_date)}" if rx.start_date else ""
st.html(
f'<div style="margin-bottom:6px"><b>{escape(rx.drug)} {escape(rx.dose)}</b>'
f'<div class="mt-meta">{escape(times)} · {escape(rx.instructions or "без особых указаний")}{since}</div>'
"</div>"
)
def render(card: PatientCard, today: date | None) -> None:
card = card.model_copy(update={"alerts": h.apply_local_acks(card.alerts, state.local_acks())})
start = card.prescriptions[0].start_date if card.prescriptions else None
_header(card, today)
grid = h.dose_grid(card.doses)
adh = h.adherence_view(card.patient.adherence_7d)
open_alerts = [a for a in card.alerts if a.acked_at is None]
taken = f"{grid.counts['taken']} из {sum(grid.counts[s] for s in ('taken', 'missed', 'skipped'))} доз за курс"
st.html(
'<div class="mt-kpis">'
+ _score_tile(card, "phq9", "PHQ-9 · депрессия")
+ _score_tile(card, "pcl5", "PCL-5 · ПТСР")
+ _tile("Приём за 7 дней", adh.text, taken if grid.days else "", adh.tone)
+ _tile(
"Открытые алерты",
str(len(open_alerts)),
"есть кризисный" if any(a.level == "acute" for a in open_alerts) else "",
"red" if any(a.level == "acute" for a in open_alerts) else ("yellow" if open_alerts else "green"),
)
+ "</div>"
)
main, side = st.columns([1.75, 1], gap="large")
with main:
with st.container(border=True):
c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start)
with col:
if fig is None:
st.caption("Шкала ещё не проводилась")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True):
fig = charts.wellbeing_figure(card.series, start)
if fig is None:
st.caption("Ежедневных чек-инов пока нет")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True):
st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start))
with side:
with st.container(border=True):
_alerts_feed(card.alerts, today)
with st.container(border=True):
_summary(card.summary)
with st.container(border=True):
_safety_plan(card.safety_plan)
with st.container(border=True):
_prescriptions(card)
+62
View File
@@ -0,0 +1,62 @@
"""Session-level plumbing shared by all dashboard pages: API client, demo "today", local acks, error banners."""
from __future__ import annotations
import os
from collections.abc import Callable
from datetime import date, datetime
from html import escape
from typing import TypeVar
import streamlit as st
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
from dashboard.components import ui
T = TypeVar("T")
# Short doctor names for the header; no other doctor PII is shown.
DOCTORS = {"doc-smirnova": ("д-р Смирнова Е. А.", "д-ра Смирновой Е. А.")}
PATIENTS_PAGE = "pages/1_Пациенты.py"
CONNECT_PAGE = "pages/1b_Подключить_пациента.py"
@st.cache_resource
def _client(base_url: str | None, doctor_id: str | None) -> ApiClient:
return ApiClient(base_url, doctor_id)
def client() -> ApiClient:
return _client(os.environ.get("API_BASE_URL"), os.environ.get("DOCTOR_ID"))
def doctor_names() -> tuple[str, str]:
did = client().doctor_id
return DOCTORS.get(did, (did, did))
def now() -> datetime | None:
"""Demo clock from the API (ADR-005), so day 13 of the scenario is "today" on screen; None if unknown."""
c = client().clock()
return c.now if c else None
def today() -> date | None:
n = now()
return n.date() if n else None
def local_acks() -> dict[int, tuple[str, datetime]]:
return st.session_state.setdefault("acked_local", {})
def call(fn: Callable[[], T]) -> T | None:
"""Run an API call; on failure show a calm banner instead of a traceback and return None."""
try:
return fn()
except ApiUnavailable as exc:
st.html(ui.banner(f"<b>API недоступен.</b> {escape(exc.message)}. Данные обновятся автоматически.", "grey"))
except ApiError as exc:
st.html(ui.banner(f"<b>Не удалось загрузить данные.</b> {escape(exc.message)}", "grey"))
return None
+126
View File
@@ -0,0 +1,126 @@
"""HTML fragments of the dashboard. Builders return strings (testable); `st.html` renders them.
Every value coming from the API is escaped: patient free text never reaches these views, but the API is still
treated as untrusted input.
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import PatientListItem
from dashboard import theme
from dashboard.components import helpers as h
def chip(text: str) -> str:
return f'<span class="mt-chip">{escape(text)}</span>'
def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str:
tip_attr = f' data-tip="{escape(tip)}" title="{escape(tip)}" tabindex="0"' if tip else ""
cls = " mt-tip" if tip else ""
lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon
return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>'
def bar(fraction: float | None, tone: str) -> str:
pct = 0 if fraction is None else max(0, min(100, round(fraction * 100)))
return f'<div class="mt-bar"><span style="width:{pct}%;background:{theme.TONES[tone].solid}"></span></div>'
def page_header(title: str, subtitle: str = "", kicker: str = "") -> str:
k = f'<div class="mt-kicker">{escape(kicker)}</div>' if kicker else ""
s = f'<div class="mt-sub">{subtitle}</div>' if subtitle else ""
return f'<div class="mt-head"><div>{k}<h1>{escape(title)}</h1>{s}</div></div>'
def kpi_tiles(k: h.Kpis) -> str:
tiles = [
("Всего пациентов", str(k.total), "Под вашим наблюдением", theme.INK_2),
("Срочно", str(k.red), "Кризисные уведомления", theme.TONES["red"].solid),
("Требуют внимания", str(k.yellow + k.grey), f"Из них только инфо: {k.grey}", theme.TONES["yellow"].solid),
("Без уведомлений", str(k.green), "Продолжают наблюдение", theme.TONES["green"].solid),
]
body = "".join(
f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>'
f'<div class="val">{val}</div><div class="hint">{escape(hint)}</div></div>'
for lbl, val, hint, accent in tiles
)
return f'<div class="mt-kpis">{body}</div>'
LIST_COLUMNS = ["Пациент", "Статус", "Шкалы", "Приём 7 дн.", "Риск прерывания"]
def list_header() -> str:
return '<div class="mt-cols">' + "".join(f"<div>{c}</div>" for c in LIST_COLUMNS) + "</div>"
def _score(value: int | None, trend: str) -> str:
if value is None:
return '<span class="mt-dim">—</span>'
t = h.trend_view(trend)
return (
f'<span class="mt-num">{value}</span> '
f'<span title="{escape(t.hint)}" aria-label="{escape(t.hint)}" '
f'style="color:{theme.TONES[t.tone].ink};font-weight:600">'
f"{t.text}</span>"
)
def patient_row(p: PatientListItem, *, detailed: bool = False) -> str:
"""One row of the patient list; the grid matches `list_header()`."""
status = h.status_view(p.status_color)
chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
adh = h.adherence_view(p.adherence_7d)
risk = h.risk_view(p.discontinuation_risk)
details = ""
if detailed:
details = (
'<div class="mt-row-details">'
f'<div><span class="mt-field-label">Терапия</span>{escape(p.drug or "Нет назначения")} · {chips}</div>'
f'<div><span class="mt-field-label">Последнее событие</span>{escape(p.last_event or "Нет событий")}</div>'
'<div><span class="mt-field-label">Факторы риска прерывания</span>'
f"{escape(', '.join(p.discontinuation_risk.factors) or 'Значимых факторов нет')}</div></div>"
)
return (
'<div class="mt-row">'
f'<div class="mt-person"><div class="mt-name">{escape(p.display_name)}</div>'
f'<div class="mt-meta">{h.age_label(p.age)} · день терапии {p.therapy_day}</div></div>'
f'<div class="mt-status">{badge(status.text, status.tone, status.hint, icon="dot")}'
f'<div class="mt-meta">Открытых уведомлений: {p.open_alerts}</div></div>'
f'<div class="mt-scores"><div><span class="mt-scale-label">PHQ-9</span>{_score(p.phq9_last, p.trend)}</div>'
f'<div><span class="mt-scale-label">PCL-5</span>{_score(p.pcl5_last, p.trend)}</div></div>'
'<div class="mt-adherence"><span class="mt-mobile-label">Приём за 7 дней</span>'
f'<span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
'<div class="mt-risk"><span class="mt-mobile-label">Риск прерывания</span>'
f"{badge(risk.text, risk.tone, risk.hint)}</div>"
f"{details}</div>"
)
def workspace_header(doctor: str, page: str) -> str:
return (
'<div class="mt-workspace"><div>Кабинет врача <span aria-hidden="true"> / </span>'
f' {escape(page)}</div><div class="mt-doctor"><span class="mt-doctor-icon" aria-hidden="true">+</span>'
f"<div><strong>{escape(doctor)}</strong><small>Врач-психиатр · демо</small></div></div></div>"
)
def empty_state(title: str, hint: str) -> str:
return f'<div class="mt-empty" role="status"><strong>{escape(title)}</strong><p>{escape(hint)}</p></div>'
def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str:
lead = '<span class="mt-pulse"></span>' if pulse else ""
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
def footer() -> None:
st.html(
'<div class="mt-footer">Прототип на синтетических данных · MedITron · '
"ИИ не ставит диагнозы и не назначает лечение — решения принимает врач</div>"
)
+5
View File
@@ -0,0 +1,5 @@
"""Demo console building blocks (#17): HTTP API helpers, scenario parser, playback runner, Streamlit widgets.
Pages import this package as `console` (Streamlit puts `dashboard/` on sys.path). Modules other than
`widgets` / `chat_ui` are free of Streamlit so they can be unit-tested.
"""
+185
View File
@@ -0,0 +1,185 @@
"""Thin synchronous HTTP client for the demo console (frozen API contract, `app/api/schemas.py`).
The console never imports backend code: it talks to `API_BASE_URL` only. Responses are kept as plain dicts —
the console reads a handful of fields and must survive a backend that still serves fixtures (#12).
Every failure is raised as `ApiError` with a short Russian message, so pages can show it instead of crashing.
"""
from __future__ import annotations
import os
from typing import Any, Protocol
import httpx
DEFAULT_DOCTOR = "doc-smirnova"
DEFAULT_TOKEN = "tok-andrey"
def api_base_url() -> str:
return os.environ.get("API_BASE_URL", "http://localhost:8000").rstrip("/")
def doctor_id() -> str:
return os.environ.get("DEMO_DOCTOR_ID", DEFAULT_DOCTOR)
def fallback_token() -> str:
return os.environ.get("DEMO_PERSONA_TOKEN", DEFAULT_TOKEN)
class ApiError(RuntimeError):
"""Any API failure (network, HTTP status, bad JSON) with a message fit for the presenter."""
class DemoApi(Protocol):
"""What the playback runner needs; `ConsoleApi` implements it, tests use a fake."""
def clock(self) -> dict[str, Any]: ...
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: ...
def send_event(
self,
token: str,
type_: str,
*,
text: str | None = None,
button: str | None = None,
params: dict[str, Any] | None = None,
) -> list[dict[str, Any]]: ...
def outbox(self, token: str) -> list[dict[str, Any]]: ...
def patients(self, doctor: str) -> list[dict[str, Any]]: ...
class ConsoleApi:
"""Client for `/api/v1`. Short timeouts everywhere except clock moves, which replay the scheduler."""
def __init__(self, base_url: str | None = None, *, timeout: float = 5.0, client: httpx.Client | None = None):
self.base_url = (base_url or api_base_url()).rstrip("/")
self._timeout = timeout
self._client = client or httpx.Client(timeout=timeout)
# ------------------------------------------------------------------ plumbing
def _request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any:
url = f"{self.base_url}{path}"
try:
resp = self._client.request(method, url, timeout=timeout or self._timeout, **kwargs)
except httpx.TimeoutException as exc:
raise ApiError(f"{method} {path}: API не ответил вовремя") from exc
except httpx.HTTPError as exc:
raise ApiError(f"{method} {path}: API недоступен ({type(exc).__name__})") from exc
if resp.status_code >= 400:
raise ApiError(f"{method} {path}: HTTP {resp.status_code}")
try:
return resp.json()
except ValueError as exc:
raise ApiError(f"{method} {path}: ответ не JSON") from exc
def close(self) -> None:
self._client.close()
# ------------------------------------------------------------------ service
def health(self) -> bool:
try:
return self._request("GET", "/health", timeout=2).get("status") == "ok"
except (ApiError, AttributeError):
return False
# ------------------------------------------------------------------ demo controls (ADR-005)
def personas(self) -> list[dict[str, Any]]:
return list(self._request("GET", "/api/v1/demo/personas"))
def clock(self) -> dict[str, Any]:
return dict(self._request("GET", "/api/v1/demo/clock"))
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]:
# The backend replays the scheduler up to the new time inside this call: allow it time.
body = {"days": days, "hours": hours}
return dict(self._request("POST", "/api/v1/demo/clock/advance", json=body, timeout=60))
def reset(self) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/reset", timeout=60))
# ------------------------------------------------------------------ patient dialog
def send_event(
self,
token: str,
type_: str,
*,
text: str | None = None,
button: str | None = None,
params: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
body: dict[str, Any] = {"type": type_, "params": params or {}}
if text is not None:
body["text"] = text
if button is not None:
body["button"] = button
data = self._request("POST", f"/api/v1/dialog/{token}/events", json=body, timeout=30)
return list(data.get("replies", []))
def outbox(self, token: str) -> list[dict[str, Any]]:
return list(self._request("GET", f"/api/v1/dialog/{token}/outbox").get("replies", []))
def history(self, token: str, limit: int = 100) -> list[dict[str, Any]]:
return list(self._request("GET", f"/api/v1/dialog/{token}/history", params={"limit": limit}))
# ------------------------------------------------------------------ doctor side
def patients(self, doctor: str) -> list[dict[str, Any]]:
return list(self._request("GET", f"/api/v1/doctors/{doctor}/patients"))
def alerts(self, doctor: str, since_id: int = -1, open_only: bool = False) -> list[dict[str, Any]]:
params = {"since_id": since_id, "open_only": str(open_only).lower()}
return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params))
def connect(self, body: dict[str, Any]) -> dict[str, Any]:
"""`POST /patients/connect` (ConnectPatientIn) → {token, code, deep_link}."""
return dict(self._request("POST", "/api/v1/patients/connect", json=body, timeout=30))
def ack(self, alert_id: int, doctor: str) -> dict[str, Any]:
return dict(self._request("POST", f"/api/v1/alerts/{alert_id}/ack", json={"doctor_id": doctor}))
def personas_or_fallback(api: ConsoleApi) -> tuple[list[dict[str, Any]], str | None]:
"""Demo personas for the selector, degrading step by step so the console always has someone to talk as.
1. `GET /demo/personas` (patients with a scenario file);
2. the demo doctor's patient list (real tokens; no scenario file yet, e.g. before #18 lands);
3. a single persona with the token from `DEMO_PERSONA_TOKEN` (fixtures use `tok-andrey`).
Returns the personas and a note for the presenter when a fallback was used.
"""
try:
personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")]
if personas:
return personas, None
error = "API не вернул персон со сценарием"
except ApiError as exc:
error = str(exc)
try:
patients = [p for p in api.patients(doctor_id()) if isinstance(p, dict) and p.get("token")]
except ApiError:
patients = []
if patients:
# The demo hero first: the scenario file is guessed from the name later (`default_scenario_path`).
patients.sort(key=lambda p: not str(p.get("display_name", "")).startswith("Андрей"))
personas = [
{
"token": p["token"],
"display_name": p.get("display_name") or p["token"],
"therapy_day": p.get("therapy_day", 0),
"scenario": None,
}
for p in patients
]
return personas, f"{error}; показаны пациенты врача"
token = fallback_token()
fallback = {
"token": token,
"display_name": "Андрей С." if token == DEFAULT_TOKEN else token,
"therapy_day": 0,
"scenario": None,
}
return [fallback], f"{error}; используется токен из окружения"
+81
View File
@@ -0,0 +1,81 @@
"""In-memory transcript of the patient chat shown by the console (kept in Streamlit session state).
Entries are plain dicts: `{"dir": "out", "text", "kind", "buttons"}` for bot replies (the `Reply` contract)
and `{"dir": "in", "text", "pressed": bool}` for what the patient sent.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
MAX_ENTRIES = 400
def normalize_reply(raw: dict[str, Any]) -> dict[str, Any]:
"""Coerce an API reply into a safe shape: missing fields get defaults, malformed buttons are dropped."""
rows: list[list[dict[str, str]]] = []
for row in raw.get("buttons") or []:
if not isinstance(row, list):
continue
clean = [
{"label": str(b.get("label", "")), "data": str(b.get("data", ""))}
for b in row
if isinstance(b, dict) and b.get("data") is not None
]
if clean:
rows.append(clean)
return {
"dir": "out",
"text": str(raw.get("text") or ""),
"kind": str(raw.get("kind") or "normal"),
"buttons": rows,
}
class ChatLog:
"""Wraps a list owned by the caller (session state), so every page sees the same transcript."""
def __init__(self, entries: list[dict[str, Any]] | None = None):
self.entries: list[dict[str, Any]] = entries if entries is not None else []
def _trim(self) -> None:
if len(self.entries) > MAX_ENTRIES:
del self.entries[: len(self.entries) - MAX_ENTRIES]
def add_in(self, text: str, *, pressed: bool = False) -> None:
self.entries.append({"dir": "in", "text": text, "pressed": pressed})
self._trim()
def add_replies(self, replies: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
added = [normalize_reply(r) for r in replies if isinstance(r, dict)]
self.entries.extend(added)
self._trim()
return added
def add_history(self, messages: Iterable[dict[str, Any]]) -> None:
"""Prefill from `GET /dialog/{token}/history` (MessageOut: no buttons)."""
for m in messages:
if not isinstance(m, dict):
continue
if m.get("direction") == "in":
self.entries.append({"dir": "in", "text": str(m.get("text", "")), "pressed": False})
else:
self.entries.append(normalize_reply({"text": m.get("text"), "kind": m.get("kind")}))
self._trim()
def recent_replies(self, n: int = 10) -> list[dict[str, Any]]:
"""Last `n` bot replies, oldest first."""
outs = [e for e in self.entries if e.get("dir") == "out"]
return outs[-n:] if n > 0 else []
def last_buttons_index(self) -> int | None:
"""Index of the newest bot reply that carries buttons — only its keyboard is clickable in the web chat."""
for i in range(len(self.entries) - 1, -1, -1):
e = self.entries[i]
if e.get("dir") == "out" and e.get("buttons"):
return i
return None
def clear(self) -> None:
self.entries.clear()
+312
View File
@@ -0,0 +1,312 @@
"""Scenario playback over the HTTP API (#17): runs `data/synthetic/<persona>.jsonl` step by step.
Time handling. A scenario day is a calendar date on the demo clock. When day N is played, its date is
derived from the last played day M: date(N) = date(M) + (N − M); with nothing played yet (or if that date
is already behind the clock, e.g. after manual «Следующий день») `advance_to HH:MM` goes to the next
occurrence of HH:MM. The clock only moves forward (`/demo/clock/advance` replays the scheduler), so a target
already in the past is skipped with a warning instead of failing.
Failure handling. Every step is wrapped: an error stops playback, the position is kept in `PlaybackState`
and the page offers «повторить шаг» / «пропустить шаг» / «продолжить вручную». The demo never crashes.
"""
from __future__ import annotations
import time as _time
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from .api import DEFAULT_DOCTOR, ApiError, DemoApi
from .chatlog import ChatLog
from .scenario import Action, DayScript, Scenario, parse_hhmm
# Virtual time keeps ticking between steps: a target a few minutes behind "now" counts as reached.
TOLERANCE = timedelta(minutes=5)
PRESS_WINDOW = 10
StepStatus = Literal["ok", "warn", "fail", "info"]
# ------------------------------------------------------------------------------------------------ pure helpers
def _norm(s: str) -> str:
return " ".join(s.casefold().replace("ё", "е").split())
def resolve_press(replies: Sequence[dict[str, Any]], label: str, window: int = PRESS_WINDOW) -> tuple[str, str] | None:
"""Find the button to press for a scenario `press` step.
Searches the last `window` replies newest first; inside a reply an exact (case/ё-insensitive) label match
wins over a substring match. Returns `(data, label)` or None. Button `data` is dynamic (dose ids), so
scenarios address buttons by label.
"""
needle = _norm(label)
if not needle:
return None
for reply in reversed(list(replies)[-window:] if window > 0 else []):
buttons = [b for row in reply.get("buttons") or [] for b in row if isinstance(b, dict)]
for exact in (True, False):
for b in buttons:
text = _norm(str(b.get("label", "")))
if (text == needle) if exact else (needle in text):
return str(b.get("data", "")), str(b.get("label", ""))
return None
def parse_clock_now(clock: dict[str, Any]) -> datetime:
"""`ClockOut.now` (ISO, tz-aware) → datetime; raises ValueError on garbage."""
raw = clock.get("now")
if not isinstance(raw, str):
raise ValueError("часы API не вернули поле now")
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
def target_datetime(now: datetime, hhmm: str, day_date: date | None) -> datetime:
"""Moment to move the clock to for `advance_to HH:MM` (see module docstring for the rules)."""
h, m = parse_hhmm(hhmm)
if day_date is None:
cand = now.replace(hour=h, minute=m, second=0, microsecond=0)
if cand < now - TOLERANCE:
cand += timedelta(days=1)
return cand
return datetime.combine(day_date, time(h, m), tzinfo=now.tzinfo)
def hours_until(now: datetime, target: datetime) -> float | None:
"""Hours to advance (≥ 0), 0 within the tolerance window, None if the target is clearly in the past."""
delta = target - now
if delta >= timedelta(0):
return delta.total_seconds() / 3600
if delta >= -TOLERANCE:
return 0.0
return None
def planned_date(dates: dict[int, date], day: int, today: date) -> date | None:
"""Calendar date for scenario `day` from already played days, or None to use "next occurrence" rules."""
if day in dates:
cand = dates[day]
else:
earlier = [d for d in dates if d < day]
if not earlier:
return None
prev = max(earlier)
cand = dates[prev] + timedelta(days=day - prev)
return cand if cand >= today else None
# ------------------------------------------------------------------------------------------------ runner
@dataclass
class StepLog:
day: int
index: int # 0-based step index inside the day; -1 for day-level messages
status: StepStatus
message: str
action: Action | None = None
def render(self) -> str:
icon = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "ℹ️"}[self.status]
step = f"шаг {self.index + 1}: " if self.index >= 0 else ""
return f"{icon} день {self.day}, {step}{self.message}"
@dataclass
class Resume:
day: int
step: int
until: int
@dataclass
class PlaybackState:
"""Per-persona playback memory kept in session state; survives an interrupted Streamlit run."""
dates: dict[int, date] = field(default_factory=dict) # scenario day → calendar date it was played on
done: list[int] = field(default_factory=list) # fully played days
failed: Resume | None = None # where playback stopped on an error
position: Resume | None = None # step currently being executed (left set if the run was interrupted)
log: list[str] = field(default_factory=list)
@property
def last_done(self) -> int | None:
return max(self.done) if self.done else None
def reset(self) -> None:
self.dates.clear()
self.done.clear()
self.failed = None
self.position = None
self.log.clear()
class StepFailed(Exception):
pass
class PlaybackRunner:
def __init__(
self,
api: DemoApi,
token: str,
chat: ChatLog,
state: PlaybackState,
*,
doctor: str = DEFAULT_DOCTOR,
delay: float = 0.0,
press_retries: int = 3,
retry_wait: float = 0.7,
sleep: Callable[[float], None] = _time.sleep,
):
self.api = api
self.token = token
self.chat = chat
self.state = state
self.doctor = doctor
self.delay = delay
self.press_retries = press_retries
self.retry_wait = retry_wait
self.sleep = sleep
# ---------------------------------------------------------------- helpers
def drain(self) -> list[dict[str, Any]]:
"""Pull system-initiated replies (reminders, check-ins) into the chat."""
return self.chat.add_replies(self.api.outbox(self.token))
def _now(self) -> datetime:
return parse_clock_now(self.api.clock())
@staticmethod
def _preview(replies: list[dict[str, Any]]) -> str:
if not replies:
return ""
text = replies[-1].get("text", "").replace("\n", " ")
more = f" (+{len(replies) - 1})" if len(replies) > 1 else ""
return f" → «{text[:70]}{'…' if len(text) > 70 else ''}»{more}"
def next_day(self, scenario: Scenario) -> DayScript | None:
last = self.state.last_done
return next((d for d in scenario.days if last is None or d.day > last), None)
# ---------------------------------------------------------------- one step
def _step(self, day: DayScript, action: Action, day_date: list[date | None]) -> tuple[StepStatus, str]:
if action.kind == "advance_to":
now = self._now()
target = target_datetime(now, action.value, day_date[0])
if day_date[0] is None:
day_date[0] = target.date()
self.state.dates[day.day] = target.date()
hours = hours_until(now, target)
if hours is None:
return "warn", f"{action.describe()}: время уже прошло ({now:%d.%m %H:%M}), пропускаю"
if hours > 0:
self.api.advance(hours=hours)
got = self.drain()
return "ok", f"{action.describe()} ({target:%d.%m %H:%M}){self._preview(got)}"
if action.kind == "press":
found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value)
for _ in range(self.press_retries):
if found is not None:
break
# The reply may still be on its way (scheduler tick): wait and drain the outbox again.
self.sleep(self.retry_wait)
self.drain()
found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value)
if found is None:
raise StepFailed(f"кнопка «{action.value}» не найдена в последних {PRESS_WINDOW} сообщениях")
data, label = found
self.chat.add_in(label, pressed=True)
got = self.chat.add_replies(self.api.send_event(self.token, "button", button=data))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
if action.kind == "text":
self.chat.add_in(action.value)
got = self.chat.add_replies(self.api.send_event(self.token, "text", text=action.value))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
if action.kind == "start":
params = {"code": action.value} if action.value else {}
self.chat.add_in("/start", pressed=True)
got = self.chat.add_replies(self.api.send_event(self.token, "start", params=params))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
# expect_color is a soft check: a mismatch is shown to the presenter but never stops the demo.
try:
patients = self.api.patients(self.doctor)
except ApiError as exc:
return "warn", f"{action.describe()}: не удалось проверить ({exc})"
me = next((p for p in patients if isinstance(p, dict) and p.get("token") == self.token), None)
if me is None:
return "warn", f"{action.describe()}: пациента нет в списке врача"
actual = me.get("status_color")
if actual == action.value:
return "ok", f"{action.describe()}: совпало"
return "warn", f"{action.describe()}: у врача сейчас {actual}"
# ---------------------------------------------------------------- days
def run(self, scenario: Scenario, from_day: int, from_step: int, until_day: int) -> Iterator[StepLog]:
"""Play days `from_day..until_day` (those present in the scenario), the first one from `from_step`."""
days = [d for d in scenario.days if from_day <= d.day <= until_day]
if not days:
yield StepLog(from_day, -1, "info", "в сценарии нет дней для проигрывания")
return
self.state.failed = None
for n, day in enumerate(days):
start = from_step if n == 0 else 0
try:
today = self._now().date()
except (ApiError, ValueError) as exc:
self._fail(day.day, max(start, 0), until_day)
yield StepLog(day.day, -1, "fail", f"часы API недоступны: {exc}")
return
day_date: list[date | None] = [planned_date(self.state.dates, day.day, today)]
if day_date[0] is not None:
self.state.dates[day.day] = day_date[0]
yield StepLog(day.day, -1, "info", f"начинаю день {day.day}" + (f": {day.note}" if day.note else ""))
for i in range(start, len(day.actions)):
action = day.actions[i]
self.state.position = Resume(day.day, i, until_day)
try:
status, message = self._step(day, action, day_date)
except Exception as exc: # robustness first: any error pauses playback, never crashes the page
self._fail(day.day, i, until_day)
reason = str(exc) or type(exc).__name__
yield StepLog(day.day, i, "fail", f"{action.describe()}: {reason}", action)
return
yield StepLog(day.day, i, status, message, action)
if self.delay > 0 and i < len(day.actions) - 1:
self.sleep(self.delay)
if day.day not in self.state.dates:
self.state.dates[day.day] = today
if day.day not in self.state.done:
self.state.done.append(day.day)
self.state.position = None
yield StepLog(day.day, -1, "ok", f"день {day.day} проигран")
if self.delay > 0 and n < len(days) - 1:
self.sleep(self.delay)
def _fail(self, day: int, step: int, until: int) -> None:
self.state.failed = Resume(day, step, until)
self.state.position = None
def play_day(self, scenario: Scenario, day: int) -> Iterator[StepLog]:
return self.run(scenario, day, 0, day)
def scroll_to(self, scenario: Scenario, day: int) -> Iterator[StepLog]:
"""Play every not yet played day up to and including `day`."""
nxt = self.next_day(scenario)
if nxt is None or nxt.day > day:
return iter([StepLog(day, -1, "info", f"дни до {day} уже проиграны")])
return self.run(scenario, nxt.day, 0, day)
def resume(self, scenario: Scenario, *, skip: bool) -> Iterator[StepLog]:
"""Continue after a failure (or an interrupted run): retry the failed step or skip it."""
point = self.state.failed or self.state.position
if point is None:
return iter([StepLog(0, -1, "info", "нечего продолжать")])
step = point.step + (1 if skip else 0)
return self.run(scenario, point.day, step, point.until)
+106
View File
@@ -0,0 +1,106 @@
"""Scenario playback panel of the demo console (#17): day list, «Проиграть день N», «Прокрутить до дня N»."""
from __future__ import annotations
from typing import Any
import streamlit as st
from . import widgets as w
from .playback import PlaybackState
from .scenario import Scenario
def _forget_upload(token: str) -> None:
st.session_state.pop(f"console_scenario_text:{token}", None)
st.session_state.pop(f"console_up:{token}", None) # resets the uploader widget too
def _scenario_source_controls(persona: dict[str, Any], source: str) -> None:
token = persona["token"]
with st.expander(f"Файл сценария: {source}"):
st.text_input(
"Путь в репозитории",
key=f"console_scenario_path:{token}",
placeholder=str(persona.get("scenario") or "data/synthetic/andrey.jsonl"),
)
upload = st.file_uploader("…или загрузить .jsonl", type=["jsonl", "json", "txt"], key=f"console_up:{token}")
if upload is not None:
try:
st.session_state[f"console_scenario_text:{token}"] = upload.getvalue().decode("utf-8")
except UnicodeDecodeError:
st.error("Файл не в UTF-8")
if st.session_state.get(f"console_scenario_text:{token}"):
st.button("Забыть загруженный файл", on_click=_forget_upload, args=(token,))
def _day_table(scenario: Scenario, state: PlaybackState, next_day: int | None) -> None:
rows = []
for d in scenario.days:
mark = "✅" if d.day in state.done else ("▶️" if d.day == next_day else "")
rows.append({"": mark, "День": d.day, "Шагов": len(d.actions), "Подсказка ведущему": d.note})
st.dataframe(rows, hide_index=True, width="stretch", height=min(38 + 35 * len(rows), 330))
def _clear_failure(state: PlaybackState) -> None:
state.failed = None
state.position = None
def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"]
scenario, source = w.scenario_for(persona)
state = w.playback_state(token)
st.subheader("🎬 Сценарий демо")
_scenario_source_controls(persona, source)
if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
for err in scenario.errors:
st.write(f"- {err}")
if not scenario.days:
st.info("Сценарий не загружен. Демо можно вести вручную: чат слева и кнопки часов сверху.")
return
delay = st.slider("Пауза между шагами, с", 0.0, 3.0, 1.0, 0.25, key=f"console_delay:{token}")
runner = w.make_runner(persona, delay=delay)
nxt = runner.next_day(scenario)
_day_table(scenario, state, nxt.day if nxt else None)
numbers = scenario.day_numbers
default = numbers.index(nxt.day) if nxt else len(numbers) - 1
day = st.selectbox("День", numbers, index=default, key=f"console_day:{token}", format_func=lambda n: f"день {n}")
c1, c2, c3 = st.columns(3)
action: tuple[str, Any] | None = None
if c1.button(f"▶ Проиграть день {day}", type="primary", width="stretch"):
action = ("Проигрываю день", runner.play_day(scenario, day))
if c2.button(f"⏭ Прокрутить до дня {day}", width="stretch", help="Быстро, без пауз: все непроигранные дни"):
runner.delay = 0.0
action = (f"Прокрутка до дня {day}", runner.scroll_to(scenario, day))
if c3.button(
f"▶ Следующий: день {nxt.day}" if nxt else "Все дни проиграны",
width="stretch",
disabled=nxt is None,
):
assert nxt is not None
action = ("Проигрываю день", runner.play_day(scenario, nxt.day))
point = state.failed or state.position
if point is not None and action is None:
what = "остановлено на ошибке" if state.failed else "прервано"
st.warning(f"Проигрывание {what}: день {point.day}, шаг {point.step + 1}.")
r1, r2, r3 = st.columns(3)
if r1.button("🔁 Повторить шаг", width="stretch"):
action = ("Продолжаю", runner.resume(scenario, skip=False))
if r2.button("⏩ Пропустить шаг", width="stretch"):
action = ("Продолжаю", runner.resume(scenario, skip=True))
r3.button("✋ Продолжить вручную", width="stretch", on_click=_clear_failure, args=(state,))
if action is not None:
title, logs = action
w.execute(logs, state, title=title)
st.session_state["console_log_open"] = True
st.rerun() # refresh the chat and the clock with what playback produced
if state.log:
with st.expander("Журнал последнего проигрывания", expanded=st.session_state.pop("console_log_open", False)):
st.text("\n".join(state.log[-80:]))
+180
View File
@@ -0,0 +1,180 @@
"""Demo scenario files `data/synthetic/<persona>.jsonl` (format agreed with #18).
One JSON object per line: `{"day": <therapy day>, "note": "<presenter hint>", "actions": [...]}` where an action
is exactly one of `{"advance_to": "HH:MM"}`, `{"press": "<label substring>"}`, `{"text": "<message>"}`,
`{"expect_color": "green|yellow|red|grey"}`. The console also accepts `{"start": "<connect code or empty>"}`
(sends a `start` event) so a scenario can open the dialog itself.
The parser is lenient on purpose: a broken line or action is reported and skipped, the rest still plays —
a typo in the scenario must not take the live demo down.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
ActionKind = Literal["advance_to", "press", "text", "expect_color", "start"]
ACTION_KINDS: tuple[ActionKind, ...] = ("advance_to", "press", "text", "expect_color", "start")
COLORS = ("green", "yellow", "red", "grey")
_HHMM = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
REPO_ROOT = Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class Action:
kind: ActionKind
value: str
def describe(self) -> str:
"""Short Russian description for the step log."""
return {
"advance_to": f"⏩ часы → {self.value}",
"press": f"🔘 нажать «{self.value}»",
"text": f"💬 написать «{self.value}»",
"expect_color": f"🎯 ожидаем цвет {self.value}",
"start": "🚀 начать диалог" + (f" (код {self.value})" if self.value else ""),
}[self.kind]
@dataclass(frozen=True)
class DayScript:
day: int
note: str
actions: tuple[Action, ...]
@dataclass
class Scenario:
days: list[DayScript] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def day(self, number: int) -> DayScript | None:
return next((d for d in self.days if d.day == number), None)
@property
def day_numbers(self) -> list[int]:
return [d.day for d in self.days]
def parse_hhmm(value: str) -> tuple[int, int]:
"""'8:05' / '08:05' → (8, 5); raises ValueError on anything else."""
m = _HHMM.match(value.strip())
if m is None:
raise ValueError(f"время должно быть в формате ЧЧ:ММ, получено «{value}»")
return int(m.group(1)), int(m.group(2))
def parse_action(raw: Any) -> Action:
"""Validate one action object; raises ValueError with a Russian message."""
if not isinstance(raw, dict) or len(raw) != 1:
raise ValueError(f"действие должно быть объектом с одним ключом, получено {raw!r}")
((kind, value),) = raw.items()
if kind not in ACTION_KINDS:
raise ValueError(f"неизвестное действие «{kind}»")
if value is None and kind == "start":
value = ""
if not isinstance(value, str):
raise ValueError(f"значение «{kind}» должно быть строкой")
value = value.strip()
if kind == "advance_to":
h, m = parse_hhmm(value)
value = f"{h:02d}:{m:02d}"
elif kind == "expect_color":
value = value.lower()
if value not in COLORS:
raise ValueError(f"цвет должен быть одним из {', '.join(COLORS)}, получено «{value}»")
elif kind in ("press", "text") and not value:
raise ValueError(f"пустое значение «{kind}»")
return Action(kind, value) # type: ignore[arg-type]
def parse_scenario(text: str) -> Scenario:
"""Parse JSONL text. Days are returned sorted; duplicates keep the first occurrence."""
scenario = Scenario()
seen: set[int] = set()
for lineno, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line or line.startswith("//") or line.startswith("#"):
continue
where = f"строка {lineno}"
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
scenario.errors.append(f"{where}: не JSON ({exc.msg})")
continue
if not isinstance(obj, dict):
scenario.errors.append(f"{where}: ожидается объект")
continue
day = obj.get("day")
if isinstance(day, bool) or not isinstance(day, int) or day < 0:
scenario.errors.append(f"{where}: поле day должно быть целым ≥ 0")
continue
if day in seen:
scenario.errors.append(f"{where}: день {day} повторяется, строка пропущена")
continue
raw_actions = obj.get("actions", [])
if not isinstance(raw_actions, list):
scenario.errors.append(f"{where}: actions должно быть списком")
continue
actions: list[Action] = []
for i, raw in enumerate(raw_actions, start=1):
try:
actions.append(parse_action(raw))
except ValueError as exc:
scenario.errors.append(f"{where}, день {day}, действие {i}: {exc} — пропущено")
note = obj.get("note", "")
seen.add(day)
scenario.days.append(DayScript(day=day, note=str(note) if note is not None else "", actions=tuple(actions)))
scenario.days.sort(key=lambda d: d.day)
return scenario
def resolve_path(path: str | Path, root: Path = REPO_ROOT) -> Path:
p = Path(path)
return p if p.is_absolute() else root / p
def load_scenario(path: str | Path, root: Path = REPO_ROOT) -> Scenario:
"""Read and parse a scenario file; a missing/unreadable file yields an empty scenario with an error."""
full = resolve_path(path, root)
try:
text = full.read_text(encoding="utf-8")
except FileNotFoundError:
return Scenario(errors=[f"файл сценария не найден: {path}"])
except OSError as exc:
return Scenario(errors=[f"не удалось прочитать {path}: {type(exc).__name__}"])
return parse_scenario(text)
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
"""'Андрей С.' → 'andrey': the backend names scenario files by the transliterated first name (#12)."""
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def default_scenario_path(persona: dict[str, Any], root: Path = REPO_ROOT) -> str | None:
"""Scenario path from the persona, else `data/synthetic/<slug>.jsonl` guessed from the name or `tok-<slug>`."""
if persona.get("scenario"):
return str(persona["scenario"])
token = str(persona.get("token", ""))
slugs = [persona_slug(str(persona.get("display_name", "")))]
if token.startswith("tok-"):
slugs.append(token.removeprefix("tok-"))
for slug in filter(None, slugs):
guess = f"data/synthetic/{slug}.jsonl"
if resolve_path(guess, root).exists():
return guess
return None
+455
View File
@@ -0,0 +1,455 @@
"""Streamlit widgets shared by the demo console and the split screen (#17).
Robustness rules for the live demo: every API call goes through `ConsoleApi` (raises `ApiError` only),
callbacks never raise — errors are queued as flash messages and shown on the next render — and the chat
transcript / playback progress live in `st.session_state`, so both pages share them within one browser tab.
"""
from __future__ import annotations
import contextlib
import os
from datetime import datetime
from typing import Any
import streamlit as st
from .api import ApiError, ConsoleApi, api_base_url, doctor_id, personas_or_fallback
from .chatlog import ChatLog
from .playback import PlaybackRunner, PlaybackState, StepLog, parse_clock_now, target_datetime
from .scenario import Scenario, default_scenario_path, load_scenario, parse_scenario
COLOR_EMOJI = {"red": "🔴", "yellow": "🟡", "green": "🟢", "grey": "⚪"}
LEVEL_EMOJI = {"acute": "🔴", "high": "🟠", "moderate": "🟡", "info": "⚪"}
WEEKDAYS = ("пн", "вт", "ср", "чт", "пт", "сб", "вс")
POLL_SECONDS = 2
# ------------------------------------------------------------------------------------------------ state
@st.cache_resource
def _api_for(base_url: str) -> ConsoleApi:
return ConsoleApi(base_url)
def get_api() -> ConsoleApi:
return _api_for(api_base_url())
def chat_for(token: str) -> ChatLog:
key = f"console_chat:{token}"
if key not in st.session_state:
entries: list[dict[str, Any]] = []
st.session_state[key] = entries
with contextlib.suppress(ApiError): # history is a nice-to-have: the chat may start empty
ChatLog(entries).add_history(get_api().history(token))
return ChatLog(st.session_state[key])
def playback_state(token: str) -> PlaybackState:
key = f"console_playback:{token}"
if key not in st.session_state:
st.session_state[key] = PlaybackState()
return st.session_state[key]
def flash(message: str, level: str = "error") -> None:
"""Report a problem from a callback: a toast now plus a persistent note shown by `show_flash`."""
icon = {"error": "❌", "warning": "⚠️", "success": "✅"}.get(level, "ℹ️")
# Outside a script run context (tests, threads) a toast may fail — the persistent note below is enough.
with contextlib.suppress(Exception):
st.toast(message, icon=icon)
st.session_state["console_last_note"] = (level, message)
def show_flash(container: Any = None) -> None:
"""Last error/notice of the session, kept until the next one (does not vanish with the 2 s poll)."""
note = st.session_state.get("console_last_note")
if not note:
return
level, message = note
where = container or st.sidebar
{"error": where.error, "warning": where.warning, "success": where.success}.get(level, where.info)(message)
def forget_demo_state() -> None:
"""After «Сброс демо»: drop transcripts, playback progress and the alert baseline of every persona."""
for key in list(st.session_state.keys()):
if str(key).startswith(("console_chat:", "console_playback:", "console_alerts_")):
del st.session_state[key]
# ------------------------------------------------------------------------------------------------ persona
@st.cache_data(ttl=15, show_spinner=False)
def _personas(base_url: str) -> tuple[list[dict[str, Any]], str | None]:
return personas_or_fallback(get_api())
def select_persona(container: Any = None) -> dict[str, Any]:
"""Persona selector (sidebar by default); the choice is shared by all console pages."""
where = container or st.sidebar
personas, error = _personas(get_api().base_url)
if error:
where.caption(f"⚠️ {error}")
tokens = [p["token"] for p in personas]
current = st.session_state.get("console_token")
index = tokens.index(current) if current in tokens else 0
labels = {p["token"]: f"{p.get('display_name') or p['token']} ({p['token'][:12]})" for p in personas}
token = where.selectbox("Пациент демо", tokens, index=index, format_func=lambda t: labels.get(t, t))
st.session_state["console_token"] = token
return next(p for p in personas if p["token"] == token)
# Synthetic demo hero (docs/product/01_demo_persona.md); the SNILS is fake and used only to derive the token.
DEMO_HERO = {
"display_name": "Андрей С.",
"birth_year": 1992,
"sex": "m",
"snils": "000-000-017 17",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00"],
"instructions": "1 раз в сутки утром после еды",
"modules": ["veteran", "depression"],
}
def _connect_hero() -> None:
name = st.session_state.get("console_hero_name") or DEMO_HERO["display_name"]
body = {**DEMO_HERO, "doctor_id": doctor_id(), "display_name": name}
try:
out = get_api().connect(body)
except ApiError as exc:
hint = " — пациент уже подключён, выберите его в списке" if "409" in str(exc) else ""
flash(f"Подключение не выполнено: {exc}{hint}", "warning" if hint else "error")
return
token = str(out.get("token", ""))
st.session_state["console_token"] = token
st.session_state[f"console_code:{token}"] = str(out.get("code", ""))
_personas.clear()
flash(f"Пациент подключён, код {out.get('code', '')}. Нажмите «Начать» в чате.", "success")
def render_connect_hero(container: Any = None) -> None:
"""Fallback when the demo seed is missing: connect the synthetic hero through the doctor API."""
where = container or st.sidebar
with where.expander("Подключить демо-пациента"):
st.caption("Если сид демо не загружен: врач подключает Андрея (синтетика) — как на приёме.")
st.text_input("Имя", value="Андрей С.", key="console_hero_name")
st.button("Подключить", key="console-connect", on_click=_connect_hero)
# ------------------------------------------------------------------------------------------------ clock
def _drain_quiet(api: ConsoleApi, token: str) -> None:
try:
chat_for(token).add_replies(api.outbox(token))
except ApiError as exc:
flash(f"Не удалось забрать входящие: {exc}", "warning")
def _advance(token: str, days: float = 0, hours: float = 0) -> None:
api = get_api()
try:
api.advance(days=days, hours=hours)
except ApiError as exc:
flash(f"Часы не сдвинулись: {exc}")
return
_drain_quiet(api, token)
def _advance_to(token: str, hhmm: str) -> None:
api = get_api()
try:
now = parse_clock_now(api.clock())
hours = (target_datetime(now, hhmm, None) - now).total_seconds() / 3600
if hours > 0:
api.advance(hours=hours)
except (ApiError, ValueError) as exc:
flash(f"Часы не сдвинулись: {exc}")
return
_drain_quiet(api, token)
def _reset() -> None:
try:
get_api().reset()
except ApiError as exc:
flash(f"Сброс не выполнен: {exc}")
return
forget_demo_state()
_personas.clear() # the seed may be recreated on reset
flash("Демо сброшено: часы вернулись к реальному времени, чат и прогресс сценария очищены.", "success")
def _therapy_day(api: ConsoleApi, persona: dict[str, Any]) -> int | None:
try:
me = next((p for p in api.patients(doctor_id()) if p.get("token") == persona["token"]), None)
except ApiError:
me = None
day = (me or persona).get("therapy_day")
return day if isinstance(day, int) else None
def format_now(now: datetime) -> str:
return f"{WEEKDAYS[now.weekday()]} {now:%d.%m.%Y} · {now:%H:%M}"
def render_clock_bar(persona: dict[str, Any], *, key: str, with_reset: bool = True) -> None:
"""Virtual date/time, therapy day and the time controls (ADR-005)."""
api = get_api()
token = persona["token"]
info, *buttons = st.columns([3.2, 1, 1.2, 1.2, 1.3, 1.2] if with_reset else [3.2, 1, 1.2, 1.2, 1.3])
try:
clock = api.clock()
now = parse_clock_now(clock)
day = _therapy_day(api, persona)
mode = "виртуальные часы" if clock.get("virtual") else "реальное время"
info.markdown(f"### 🕒 {format_now(now)}")
info.caption(f"День терапии: **{day if day is not None else '—'}** · {mode}")
except (ApiError, ValueError) as exc:
info.warning(f"Часы недоступны: {exc}")
buttons[0].button("+1 час", key=f"{key}-h1", on_click=_advance, args=(token,), kwargs={"hours": 1})
buttons[1].button("К 08:00 (приём)", key=f"{key}-08", on_click=_advance_to, args=(token, "08:00"))
buttons[2].button("К 20:00 (чек-ин)", key=f"{key}-20", on_click=_advance_to, args=(token, "20:00"))
buttons[3].button(
"Следующий день", key=f"{key}-d1", type="primary", on_click=_advance, args=(token,), kwargs={"days": 1}
)
if with_reset:
with buttons[4].popover("Сброс демо"):
st.write("Вернуть часы к реальному времени и очистить чат и прогресс сценария?")
st.button("Да, сбросить", key=f"{key}-reset", type="primary", on_click=_reset)
# ------------------------------------------------------------------------------------------------ chat
def _send(token: str, type_: str, *, text: str | None = None, button: str | None = None, label: str = "") -> None:
api = get_api()
chat = chat_for(token)
params: dict[str, Any] = {}
if type_ == "start":
code = (st.session_state.get(f"console_code:{token}") or "").strip()
params = {"code": code} if code else {}
chat.add_in("/start" + (f" {code}" if code else ""), pressed=True)
elif type_ == "button":
chat.add_in(label, pressed=True)
else:
chat.add_in(text or "")
try:
chat.add_replies(api.send_event(token, type_, text=text, button=button, params=params))
except ApiError as exc:
flash(f"Сообщение не доставлено: {exc}")
return
_drain_quiet(api, token)
def _submit_text(token: str, key: str) -> None:
text = (st.session_state.get(key) or "").strip()
if text:
_send(token, "text", text=text)
def _render_entry(entry: dict[str, Any]) -> None:
if entry.get("dir") == "in":
with st.chat_message("user", avatar="🧑"):
st.markdown(f"🔘 *{entry['text']}*" if entry.get("pressed") else entry["text"])
return
kind = entry.get("kind", "normal")
with st.chat_message("assistant", avatar="🤝"):
if kind == "crisis":
st.error(entry["text"], icon="🚨")
elif kind == "safety_plan":
with st.container(border=True):
st.markdown("**🛡️ План безопасности**")
st.markdown(entry["text"])
elif kind == "info":
st.caption(entry["text"])
else:
st.markdown(entry["text"])
def render_messages(token: str, *, height: int, key: str) -> None:
"""Transcript + the keyboard of the newest reply with buttons. Drains the outbox on every call."""
_drain_quiet(get_api(), token)
chat = chat_for(token)
box = st.container(height=height, border=True, autoscroll=True)
with box:
if not chat.entries:
st.caption("Сообщений пока нет. Нажмите «Начать» или сдвиньте часы к напоминанию.")
active = chat.last_buttons_index()
for i, entry in enumerate(chat.entries):
_render_entry(entry)
if i == active:
for r, row in enumerate(entry["buttons"]):
cols = st.columns(len(row))
for c, b in enumerate(row):
cols[c].button(
b["label"] or "…",
key=f"{key}-b{i}-{r}-{c}",
width="stretch",
on_click=_send,
args=(token, "button"),
kwargs={"button": b["data"], "label": b["label"]},
)
@st.fragment(run_every=POLL_SECONDS)
def _polled_messages(token: str, height: int, key: str) -> None:
render_messages(token, height=height, key=key)
def render_chat(token: str, *, height: int = 520, key: str = "chat") -> None:
"""Fallback patient web chat: polled transcript, text input, «Начать» with an optional connect code."""
_polled_messages(token, height, key)
input_key = f"{key}-input:{token}"
with st.form(f"{key}-form:{token}", clear_on_submit=True, border=False):
left, right = st.columns([5, 1])
left.text_input("Сообщение", key=input_key, label_visibility="collapsed", placeholder="Сообщение пациента…")
right.form_submit_button("➤", width="stretch", on_click=_submit_text, args=(token, input_key))
code = st.query_params.get("code") # connect links from the doctor point to the console with ?code=
if code and not st.session_state.get(f"console_code:{token}"):
st.session_state[f"console_code:{token}"] = code
with st.expander("Начать диалог / код подключения", expanded=bool(code)):
st.text_input("Код подключения (необязательно)", key=f"console_code:{token}", placeholder="DEMO-0001")
c1, c2 = st.columns(2)
c1.button("🚀 Начать", key=f"{key}-start", on_click=_send, args=(token, "start"), width="stretch")
c2.button(
"Очистить окно чата",
key=f"{key}-clear",
on_click=lambda: chat_for(token).clear(),
width="stretch",
)
def patient_web_url() -> str | None:
url = (os.environ.get("PATIENT_WEB_URL") or "").strip()
return url or None
# ------------------------------------------------------------------------------------------------ doctor panel
def _dismiss_banner(alert_id: int) -> None:
st.session_state.setdefault("console_alerts_dismissed", set()).add(alert_id)
def _ack(alert_id: int) -> None:
try:
get_api().ack(alert_id, doctor_id())
except ApiError as exc:
flash(f"Алерт не подтверждён: {exc}")
_dismiss_banner(alert_id)
def _fmt_ts(raw: Any) -> str:
try:
return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).strftime("%d.%m %H:%M")
except ValueError:
return ""
def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> None:
"""Compact doctor view for the projector: patients with colours and the live alerts feed."""
api = get_api()
doctor = doctor_id()
try:
alerts = [a for a in api.alerts(doctor) if isinstance(a, dict)]
except ApiError as exc:
alerts = []
st.warning(f"Алерты недоступны: {exc}")
ids = [int(a.get("id", -1)) for a in alerts]
# Alerts already present when the panel first loads are "old": the banner is only for new acute ones.
if "console_alerts_baseline" not in st.session_state:
st.session_state["console_alerts_baseline"] = max(ids, default=-1)
baseline = st.session_state["console_alerts_baseline"]
dismissed = st.session_state.get("console_alerts_dismissed", set())
fresh_acute = [
a
for a in alerts
if a.get("level") == "acute"
and not a.get("acked_at")
and baseline < int(a.get("id", -1))
and int(a.get("id", -1)) not in dismissed
]
for a in fresh_acute[:1]:
st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>",
unsafe_allow_html=True,
)
c1, c2 = st.columns(2)
aid = int(a.get("id", -1))
c1.button("Взять в работу (ack)", key=f"{key}-ack{aid}", type="primary", on_click=_ack, args=(aid,))
c2.button("Скрыть", key=f"{key}-hide{aid}", on_click=_dismiss_banner, args=(aid,))
st.markdown("#### Пациенты")
try:
patients = [p for p in api.patients(doctor) if isinstance(p, dict)]
except ApiError as exc:
patients = []
st.warning(f"Список пациентов недоступен: {exc}")
lines = []
for p in patients:
name = str(p.get("display_name", "?"))
if p.get("token") == highlight_token:
name = f"**{name} ◀**"
extra = f" · {p['last_event']}" if p.get("last_event") else ""
lines.append(
f"{COLOR_EMOJI.get(p.get('status_color', ''), '⚪')} {name} · день {p.get('therapy_day', '—')}{extra}"
)
st.markdown("\n".join(f"- {line}" for line in lines) if lines else "_нет пациентов_")
st.markdown("#### Алерты")
if not alerts:
st.caption("Новых алертов нет")
for a in alerts[:8]:
done = " ✔︎" if a.get("acked_at") else ""
icon = LEVEL_EMOJI.get(a.get("level", ""), "⚪")
who = a.get("patient_name", "")
st.markdown(f"{icon} `{_fmt_ts(a.get('ts'))}` **{who}** — {a.get('reason', '')}{done}")
@st.fragment(run_every=POLL_SECONDS)
def polled_doctor_panel(highlight_token: str | None, key: str = "doc") -> None:
render_doctor_panel(highlight_token, key=key)
def _escape(value: Any) -> str:
text = str(value)
for ch, rep in (("&", "&amp;"), ("<", "&lt;"), (">", "&gt;")):
text = text.replace(ch, rep)
return text
# ------------------------------------------------------------------------------------------------ playback
def scenario_for(persona: dict[str, Any]) -> tuple[Scenario, str]:
"""Scenario of the persona: uploaded text (if any) wins over the repo file. Returns (scenario, source)."""
token = persona["token"]
uploaded = st.session_state.get(f"console_scenario_text:{token}")
if uploaded:
return parse_scenario(uploaded), "загруженный файл"
path = st.session_state.get(f"console_scenario_path:{token}") or default_scenario_path(persona)
if not path:
return Scenario(errors=["у персоны нет файла сценария"]), "—"
return load_scenario(path), path
def make_runner(persona: dict[str, Any], *, delay: float) -> PlaybackRunner:
token = persona["token"]
return PlaybackRunner(get_api(), token, chat_for(token), playback_state(token), doctor=doctor_id(), delay=delay)
def execute(logs: Any, state: PlaybackState, *, title: str) -> bool:
"""Run a playback iterator with a live log. Returns True if it finished without a failed step."""
ok = True
state.log.clear()
with st.status(title, expanded=True) as status:
try:
for entry in logs:
line = entry.render() if isinstance(entry, StepLog) else str(entry)
state.log.append(line)
status.write(line)
ok = ok and entry.status != "fail"
except Exception as exc: # last-resort guard: the page must stay alive
ok = False
state.log.append(f"❌ сбой проигрывания: {exc}")
status.write(state.log[-1])
status.update(label=f"{title}: {'готово' if ok else 'остановлено'}", state="complete" if ok else "error")
return ok
+124
View File
@@ -0,0 +1,124 @@
"""Doctor's patient list ("red zones", idea 2) and, with `?patient=<token>`, the patient card."""
from __future__ import annotations
from html import escape
import streamlit as st
from dashboard.components import helpers as h
from dashboard.components import patient_card, state, ui
NAV_ICON = ":material/groups:"
COLOR_FILTERS = {
"Все пациенты": None,
"Срочно": "red",
"Внимание": "yellow",
"Нет ответа": "grey",
"Без уведомлений": "green",
}
def _open(token: str) -> None:
st.session_state["patient_list_state"] = {
key: st.session_state[key]
for key in ("status_filter", "patient_query", "patient_details")
if key in st.session_state
}
st.query_params["patient"] = token
def _close() -> None:
st.query_params.pop("patient", None)
st.session_state.update(st.session_state.pop("patient_list_state", {}))
def _reset_filters() -> None:
st.session_state["status_filter"] = "Все пациенты"
st.session_state["patient_query"] = ""
def render_card(token: str) -> None:
st.button("Все пациенты", icon=":material/arrow_back:", type="tertiary", on_click=_close)
card = state.call(lambda: state.client().patient_card(token))
if card is None:
return
patient_card.render(card, state.today())
def render_list() -> None:
_, doctor_gen = state.doctor_names()
today = state.today()
patients = state.call(lambda: state.client().patients())
subtitle = "Всё важное о состоянии пациентов — в одном месте."
if today:
subtitle += f" · данные на {h.short_date(today)}"
head, action = st.columns([4, 1.4], vertical_alignment="bottom")
with head:
st.html(ui.page_header("Пациенты под наблюдением", subtitle, kicker="Рабочее пространство"))
with action:
if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"):
st.switch_page(state.CONNECT_PAGE)
if patients is None:
st.button("Повторить загрузку", icon=":material/refresh:")
return
items = h.sort_patients(patients)
st.html(ui.kpi_tiles(h.kpis(items)))
st.html(
f'<div class="mt-section-head"><h2>Ваши пациенты <span>{len(items)}</span></h2>'
"<span>Сначала — пациенты с более высоким риском</span></div>"
)
with st.container(key="patient-list"):
f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom")
with f1:
st.session_state.setdefault("status_filter", "Все пациенты")
choice = st.segmented_control(
"Статус", list(COLOR_FILTERS), key="status_filter", label_visibility="collapsed"
)
with f2:
query = st.text_input(
"Поиск пациента", placeholder="Имя или препарат…", key="patient_query", label_visibility="collapsed"
)
detailed = st.toggle(
"Подробный список", key="patient_details", help="Назначение, последнее событие и факторы риска"
)
shown = h.filter_patients(items, COLOR_FILTERS.get(choice or "Все пациенты"), query or "")
if not items:
st.html(ui.empty_state("Пациентов пока нет", "Подключите первого пациента кнопкой вверху страницы."))
elif not shown:
st.html(ui.empty_state("Пациенты не найдены", "Измените имя, препарат или выбранный статус."))
st.button("Сбросить фильтры", on_click=_reset_filters)
else:
head_cols = st.columns([11, 1.4])
with head_cols[0]:
st.html(ui.list_header())
for p in shown:
with st.container(key=f"prow-{p.status_color}-{p.token}"):
row, btn = st.columns([11, 1.4], vertical_alignment="center")
with row:
st.html(ui.patient_row(p, detailed=detailed))
with btn:
st.button(
"Открыть",
key=f"open-{p.token}",
on_click=_open,
args=(p.token,),
help=f"Открыть карточку: {p.display_name}",
width="stretch",
)
st.html(
'<div class="mt-list-footer"><span role="status" aria-live="polite">'
f"Показано {len(shown)} из {len(items)} пациентов</span>"
f"<span>Кабинет {escape(doctor_gen)}</span></div>"
)
st.caption("Статус указан цветом и текстом. Риск прерывания терапии рассчитан по правилам; детали — в карточке.")
token = st.query_params.get("patient")
if token:
render_card(token)
else:
render_list()
@@ -0,0 +1,188 @@
"""«Подключить пациента»: after the in-person visit the doctor enters the prescription → one-time code + QR."""
from __future__ import annotations
import io
import os
from datetime import date, timedelta
from html import escape
import qrcode
import streamlit as st
from app.api.schemas import ConnectPatientIn, ConnectPatientOut
from dashboard.components import helpers as h
from dashboard.components import state, ui
NAV_ICON = ":material/person_add:"
TIME_OPTIONS = ["07:00", "08:00", "09:00", "10:00", "12:00", "14:00", "18:00", "20:00", "21:00", "22:00"]
MODULE_OPTIONS = ["veteran", "depression", "family"]
DEMO = {
"cp_name": "Андрей С.",
"cp_year": 1992,
"cp_sex": "м",
"cp_drug": "сертралин",
"cp_dose": "50 мг",
"cp_times": ["08:00"],
"cp_instr": "утром после еды",
"cp_modules": ["veteran", "depression"],
}
def qr_png(data: str) -> bytes:
img = qrcode.make(data, box_size=8, border=2)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _defaults(today: date) -> None:
ss = st.session_state
ss.setdefault("cp_name", "")
ss.setdefault("cp_year", 1990)
ss.setdefault("cp_sex", "м")
ss.setdefault("cp_snils", h.synthetic_snils())
ss.setdefault("cp_drug", "сертралин")
ss.setdefault("cp_times", ["08:00"])
ss.setdefault("cp_instr", "утром после еды")
ss.setdefault("cp_visit", today + timedelta(days=28))
ss.setdefault("cp_modules", ["veteran"])
def _fill_demo(today: date) -> None:
st.session_state.update(DEMO)
st.session_state["cp_snils"] = h.synthetic_snils()
st.session_state["cp_visit"] = today + timedelta(days=28)
def _new_snils() -> None:
st.session_state["cp_snils"] = h.synthetic_snils()
def _reset() -> None:
st.session_state.pop("cp_result", None)
for k in [*DEMO, "cp_snils", "cp_visit", "cp_consent"]:
st.session_state.pop(k, None)
def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True):
st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
"согласие и начнёт сопровождение. Код одноразовый.</div>"
)
c1, c2 = st.columns([1, 1.4], vertical_alignment="center")
with c1:
st.image(qr_png(link), width=220, caption="QR для подключения")
with c2:
st.html(f'<div class="mt-panel-title">Код подключения</div><div class="mt-code">{escape(res.code)}</div>')
st.link_button("Открыть ссылку", link, icon=":material/open_in_new:")
st.code(link, language=None)
b1, b2 = st.columns(2)
with b1:
if st.button("Карточка пациента", icon=":material/person:", width="stretch"):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": res.token})
with b2:
st.button("Подключить ещё", icon=":material/add:", on_click=_reset, width="stretch")
def main() -> None:
today = state.today() or date.today()
_defaults(today)
st.html(
ui.page_header(
"Подключить пациента",
"После очного приёма: назначение уходит в МИС (мок), пациент получает код и начинает сопровождение. "
"Только синтетические данные.",
kicker="Кабинет врача",
)
)
res: ConnectPatientOut | None = st.session_state.get("cp_result")
if res is not None:
_result(res, st.session_state.get("cp_result_name", ""))
return
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large")
with left, st.container(border=True):
st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2)
with a:
st.number_input("Год рождения", min_value=1940, max_value=today.year - 18, step=1, key="cp_year")
with b:
st.radio("Пол", ["м", "ж"], key="cp_sex", horizontal=True)
s1, s2 = st.columns([3, 1], vertical_alignment="bottom")
with s1:
st.text_input("СНИЛС (синтетический)", key="cp_snils")
with s2:
st.button("Новый", icon=":material/refresh:", on_click=_new_snils, width="stretch")
st.pills(
"Модули сопровождения",
MODULE_OPTIONS,
selection_mode="multi",
key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m],
)
with right, st.container(border=True):
st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1])
with d1:
drug = st.selectbox("Препарат (МНН)", list(h.DRUGS), key="cp_drug")
with d2:
doses = h.DRUGS[drug]
if st.session_state.get("cp_dose") not in doses:
st.session_state["cp_dose"] = doses[1] if len(doses) > 1 else doses[0]
st.selectbox("Доза", doses, key="cp_dose")
st.multiselect("Время приёма", TIME_OPTIONS, key="cp_times")
st.text_input("Указания", key="cp_instr")
st.date_input("Следующий визит", key="cp_visit", min_value=today, format="DD.MM.YYYY")
consent = st.checkbox(
"Пациент подписал информированное добровольное согласие на дистанционное наблюдение (323-ФЗ, ст. 20, 36.2)",
key="cp_consent",
)
if not st.button("Подключить и получить код", type="primary", icon=":material/qr_code_2:"):
return
ss = st.session_state
errors = []
if not ss["cp_name"].strip():
errors.append("укажите имя")
if not h.is_valid_snils(ss["cp_snils"]):
errors.append("СНИЛС не проходит контрольную сумму — нажмите «Новый»")
if not ss["cp_times"]:
errors.append("выберите время приёма")
if not ss["cp_modules"]:
errors.append("выберите хотя бы один модуль")
if not consent:
errors.append("нужно согласие пациента")
if errors:
st.error("Проверьте форму: " + "; ".join(errors))
return
body = ConnectPatientIn(
doctor_id=state.client().doctor_id,
display_name=ss["cp_name"].strip(),
birth_year=int(ss["cp_year"]),
sex="m" if ss["cp_sex"] == "м" else "f",
snils=ss["cp_snils"],
drug=ss["cp_drug"],
dose=ss["cp_dose"],
times=sorted(ss["cp_times"]),
instructions=ss["cp_instr"],
next_visit=ss["cp_visit"],
modules=ss["cp_modules"],
)
out = state.call(lambda: state.client().connect(body))
if out is not None:
ss["cp_result"] = out
ss["cp_result_name"] = body.display_name
st.rerun()
main()
@@ -0,0 +1,34 @@
"""Demo console (#17, ADR-004/005): web chat of the patient, demo clock and scenario playback.
Everything goes through the HTTP API (`API_BASE_URL`); the page keeps working when the backend serves fixtures.
"""
import sys
from pathlib import Path
import streamlit as st
# `streamlit run dashboard/app.py` already puts dashboard/ on sys.path; this keeps the page runnable on its own.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
persona = w.select_persona()
w.render_connect_hero()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль")
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).")
w.render_clock_bar(persona, key="console-clock")
st.divider()
chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col:
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat")
with play_col:
render_playback_panel(persona)
@@ -0,0 +1,58 @@
"""Split screen for the projector (#17): patient on the left, doctor on the right, clock on top.
Left: the patient web app in an iframe when `PATIENT_WEB_URL` is set (React app, e.g. http://localhost:5173),
otherwise the console's fallback web chat. Right: compact doctor panel polled every 2 s with a red banner
on a new acute alert.
"""
import sys
from pathlib import Path
import streamlit as st
# `streamlit run dashboard/app.py` already puts dashboard/ on sys.path; this keeps the page runnable on its own.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown(
"<style>.block-container{padding-top:1.2rem;max-width:100%} "
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
persona = w.select_persona()
w.show_flash()
token = persona["token"]
top, play = st.columns([6, 1.3], vertical_alignment="center")
with top:
w.render_clock_bar(persona, key="split-clock", with_reset=False)
with play:
scenario, _ = w.scenario_for(persona)
runner = w.make_runner(persona, delay=0.8)
nxt = runner.next_day(scenario) if scenario.days else None
clicked = st.button(
f"▶ Сценарий: день {nxt.day}" if nxt else "Сценарий проигран",
disabled=nxt is None,
width="stretch",
help="Проиграть следующий день из data/synthetic/<персона>.jsonl",
)
if clicked and nxt is not None:
w.execute(runner.play_day(scenario, nxt.day), w.playback_state(token), title=f"День {nxt.day}")
st.rerun()
left, right = st.columns([1.1, 1], gap="large")
with left:
url = w.patient_web_url()
if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}")
else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat")
with right:
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc")
+250
View File
@@ -0,0 +1,250 @@
"""Visual system of the doctor dashboard: status palette (ADR-006), text tokens, CSS and the plotly template.
Status colours are reserved for alert state (red = acute, yellow = high/moderate, green = nothing open,
grey = info only). They always ship together with an icon and a text label, never colour alone.
Chart series use a separate neutral blue so that a line is never confused with a status.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Tone = Literal["red", "yellow", "green", "grey", "blue"]
@dataclass(frozen=True)
class ToneSpec:
solid: str # marks, stripes, dots
ink: str # text on light surface (WCAG AA on white / on the tint)
tint: str # badge / banner background
icon: str
label: str
TONES: dict[str, ToneSpec] = {
"red": ToneSpec("#A83236", "#A83236", "#FAEEEE", "🔴", "Красная зона"),
"yellow": ToneSpec("#A37524", "#805714", "#FBF3E4", "🟡", "Жёлтая зона"),
"green": ToneSpec("#527F61", "#38634B", "#EEF4EE", "🟢", "Стабильно"),
"grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"),
"blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""),
}
INK = "#213B38"
INK_2 = "#526660"
INK_3 = "#63736F"
SURFACE = "#FFFFFF"
CANVAS = "#F6F7F3"
LINE = "#E1E7E3"
SERIES = TONES["blue"].solid
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
CSS = f"""
<style>
:root {{
--ink: {INK}; --ink-2: {INK_2}; --ink-3: {INK_3}; --line: {LINE}; --surface: {SURFACE}; --canvas: {CANVAS};
--red: {TONES["red"].solid}; --red-ink: {TONES["red"].ink}; --red-tint: {TONES["red"].tint};
--yellow: {TONES["yellow"].solid}; --yellow-ink: {TONES["yellow"].ink}; --yellow-tint: {TONES["yellow"].tint};
--green: {TONES["green"].solid}; --green-ink: {TONES["green"].ink}; --green-tint: {TONES["green"].tint};
--grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint};
--blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint};
}}
.stApp {{ background: var(--canvas); color: var(--ink); font-family: {FONT_STACK}; color-scheme: light; }}
.block-container {{ padding: 2rem 3rem 2.5rem; max-width: 1440px; }}
[data-testid="stHeader"] {{ background: var(--canvas); }}
[data-testid="stSidebar"] {{ background: var(--surface); border-right: 1px solid var(--line); }}
[data-testid="stSidebarNavLink"] {{ border-radius: 8px; color: var(--ink-2); }}
[data-testid="stSidebarNavLink"][aria-current="page"] {{ background: #edf3ee; color: #285e4e; }}
[data-testid="stSidebarNavLink"] span, [data-testid="stSidebarNav"] span {{ color: inherit; }}
[data-testid="stSidebar"] [data-testid="stMarkdownContainer"] {{ color: var(--ink-2); }}
[data-testid="stWidgetLabel"], [data-testid="stCaptionContainer"] {{ color: var(--ink-2); }}
[data-testid="stTextInputRootElement"], [data-baseweb="select"] > div {{
background: var(--surface); border-color: var(--line); color: var(--ink); border-radius: 8px; }}
[data-testid="stApp"] input, [data-testid="stApp"] textarea {{ color: var(--ink); background: var(--surface); }}
[data-testid="stBaseButton-secondary"], [data-testid="stBaseButton-tertiary"] {{
background: var(--surface); color: var(--ink); border-color: var(--line); border-radius: 8px; }}
[data-testid="stBaseButton-primary"] {{ background: #285e4e; border-color: #285e4e; color: #fff; border-radius: 8px; }}
[data-testid="stBaseButton-primary"]:hover {{ background: #204b3e; border-color: #204b3e; color: #fff; }}
[data-testid="stBaseButton-secondary"]:hover {{ background: #edf3ee; border-color: #b4c6ba; color: #285e4e; }}
button:focus-visible, a:focus-visible, input:focus-visible, [tabindex="0"]:focus-visible {{
outline: 3px solid #598d7a !important; outline-offset: 3px; }}
.mt-sidebar-note {{ margin-top:36px; border-top:1px solid var(--line); padding-top:20px; color:var(--ink-3);
font-size:.78rem; line-height:1.6; }}
.mt-sidebar-note strong {{ display:block; font-weight:550; color:var(--ink); margin-bottom:5px; }}
.mt-workspace {{ display:flex; justify-content:space-between; align-items:center; gap:16px;
border-bottom:1px solid var(--line); padding-bottom:24px; margin-bottom:20px; color:var(--ink-3); font-size:.8rem; }}
.mt-doctor {{ display:flex; gap:10px; align-items:center; }}
.mt-doctor strong {{ display:block; color:var(--ink); font-weight:550; font-size:.8rem; }}
.mt-doctor small {{ display:block; font-size:.7rem; }}
.mt-doctor-icon {{ width:34px; height:34px; background:#e7ede5; color:#285e4e; border-radius:50%;
display:grid; place-items:center; font-size:22px; }}
h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }}
/* ---------- page header ---------- */
.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap;
margin: 10px 0 12px 0; }}
.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }}
.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }}
.mt-head h1 {{ font-size: 2.15rem; font-weight: 550; margin: 0; padding: 0; line-height: 1.2; }}
.mt-sub {{ color: var(--ink-2); font-size: .9rem; margin-top: 10px; }}
.mt-kicker {{ color: var(--ink-3); font-size: .68rem; text-transform: uppercase; letter-spacing: .13em;
margin-bottom:8px;
font-weight: 600; }}
/* ---------- overview and patient list ---------- */
.mt-kpis {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); background:var(--surface);
border:1px solid var(--line); border-radius:12px; padding:22px 0; margin:10px 0 18px; }}
.mt-kpi {{ padding:0 24px; border-right:1px solid var(--line); min-width:0; }}
.mt-kpi:last-child {{ border-right:0; }}
.mt-kpi .lbl {{ color:var(--ink-2); font-size:.78rem; font-weight:500; }}
.mt-kpi .lbl::before {{ content:""; display:inline-block; width:6px; height:6px; border-radius:50%;
background:var(--kpi-accent,var(--line)); margin-right:7px; vertical-align:2px; }}
.mt-kpi .val {{ color:var(--ink); font-size:2rem; font-weight:550; line-height:1.3; margin-top:8px;
font-variant-numeric:tabular-nums; }}
.mt-kpi .hint {{ color:var(--ink-3); font-size:.72rem; margin-top:3px; }}
.mt-section-head {{ display:flex; justify-content:space-between; align-items:center; gap:16px; margin:14px 0 4px; }}
.mt-section-head h2 {{ font-size:1.25rem; font-weight:550; margin:0; padding:0; }}
.mt-section-head h2 span {{ background:#e6ece5; font-size:.75rem; padding:3px 7px; border-radius:5px;
vertical-align:middle; margin-left:6px; }}
.mt-section-head > span {{ font-size:.72rem; color:var(--ink-3); }}
.st-key-patient-list {{ background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:20px; }}
.st-key-patient-list [data-variant="segmented_control"] {{
color:var(--ink-2); background:var(--surface); border-color:var(--line); font-size:.8rem; }}
.st-key-patient-list [data-variant="segmented_control"][data-selected] {{
background:#edf3ee; color:#285e4e; border-color:#b4c6ba; font-weight:600; }}
.mt-cols, .mt-row {{ display:grid; align-items:center; gap:18px;
grid-template-columns:minmax(0,1.8fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,.8fr) minmax(0,1.2fr); }}
.mt-cols {{ color:var(--ink-3); font-size:.63rem; letter-spacing:.05em; text-transform:uppercase;
font-weight:550; padding:12px 0 4px; }}
.mt-row {{ color:var(--ink); font-size:.82rem; }}
.mt-row > div {{ min-width:0; overflow-wrap:anywhere; }}
.mt-name {{ font-weight:600; font-size:.92rem; }}
.mt-meta {{ color:var(--ink-3); font-size:.7rem; margin-top:4px; }}
.mt-num {{ font-variant-numeric:tabular-nums; font-weight:550; }}
.mt-dim {{ color:var(--ink-3); }}
.mt-dot {{ width:6px; height:6px; border-radius:50%; display:inline-block; flex:none; }}
.mt-scale-label {{ display:inline-block; min-width:42px; font-size:.67rem; color:var(--ink-3); margin-right:6px; }}
.mt-scores > div + div {{ margin-top:4px; }}
.mt-adherence .mt-bar {{ max-width:60px; height:3px; }}
.mt-mobile-label {{ display:none; }}
.mt-row-details {{ grid-column:1 / -1; display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;
font-size:.75rem; color:var(--ink-2); padding-top:12px; border-top:1px dashed var(--line); line-height:1.6; }}
.mt-field-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:4px; }}
div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4px; }}
.mt-list-footer {{ border-top:1px solid var(--line); padding-top:16px; margin-top:8px; display:flex;
justify-content:space-between; gap:12px; color:var(--ink-3); font-size:.72rem; }}
.mt-empty {{ text-align:center; padding:42px 20px; color:var(--ink-2); }}
.mt-empty strong {{ display:block; color:var(--ink); font-size:1rem; font-weight:550; }}
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }}
.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }}
.mt-badge.t-grey {{ background: var(--grey-tint); color: var(--grey-ink); }}
.mt-badge.t-blue {{ background: var(--blue-tint); color: var(--blue-ink); }}
.mt-bar {{ height: 6px; background: var(--grey-tint); border-radius: 999px; overflow: hidden; margin-top: 5px; }}
.mt-bar > span {{ display:block; height: 100%; border-radius: 999px; }}
.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center;
justify-content:center; font-weight: 700; font-size: .85rem; }}
.mt-tip {{ position: relative; cursor: help; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ content: attr(data-tip); position: absolute;
z-index: 50; left: 0; top: 120%;
min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff;
font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,.18); }}
/* ---------- banners & panels ---------- */
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
border: 1px solid; margin-bottom: 6px; }}
.mt-banner.t-red {{ background: #fffafa; border-color: #e7cbcb; border-left-width:3px; color: var(--red-ink); }}
.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }}
.mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }}
.mt-alert.t-yellow {{ border-left-color: var(--yellow); }}
.mt-alert.t-grey {{ border-left-color: var(--grey); }}
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink);
line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
/* ---------- dose strip ---------- */
.mt-doses {{ display:grid; gap: 4px; align-items:center; }}
.mt-doses .cell {{ height: 26px; border-radius: 6px; display:flex; align-items:center; justify-content:center;
font-size: .72rem; font-weight: 700; color: #fff; }}
.mt-doses .c-taken {{ background: var(--green); }}
.mt-doses .c-missed {{ background: var(--red); }}
.mt-doses .c-skipped {{ background: var(--yellow); color: #3D2B00; }}
.mt-doses .c-pending {{ background: var(--surface); border: 1.5px dashed var(--grey); color: var(--ink-3); }}
.mt-doses .c-none {{ background: transparent; }}
.mt-doses .lab {{ color: var(--ink-3); font-size: .72rem; text-align:center; font-variant-numeric: tabular-nums; }}
.mt-legend {{ display:flex; gap: 14px; flex-wrap: wrap; color: var(--ink-2); font-size: .8rem; margin-top: 8px; }}
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }}
/* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }}
.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px;
border-top: 1px solid var(--line); padding-top: 12px; }}
@media (max-width: 1100px) {{
.block-container {{ padding-left:1.5rem; padding-right:1.5rem; }}
.mt-kpi {{ padding:0 16px; }}
.mt-cols, .mt-row {{ gap:12px; }}
.mt-section-head > span {{ display:none; }}
}}
@media (max-width: 760px) {{
.block-container {{ padding:1.5rem 1rem; }}
.mt-workspace {{ flex-wrap:wrap; padding-bottom:18px; margin-bottom:10px; }}
.mt-head h1 {{ font-size:1.75rem; }}
.mt-kpis {{ grid-template-columns:repeat(2,minmax(0,1fr)); padding:0; }}
.mt-kpi {{ padding:18px; }}
.mt-kpi:nth-child(2) {{ border-right:0; }}
.mt-kpi:nth-child(-n+2) {{ border-bottom:1px solid var(--line); }}
.st-key-patient-list {{ padding:16px; }}
.mt-cols {{ display:none; }}
.mt-row {{ grid-template-columns:1fr 1fr; gap:16px; }}
.mt-person {{ grid-column:1 / -1; }}
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}}
@media (prefers-reduced-motion: reduce) {{
*, *::before, *::after {{ animation:none !important; scroll-behavior:auto !important; transition:none !important; }}
}}
</style>
"""
def plotly_layout(**overrides: object) -> dict[str, object]:
"""Shared plotly layout: recessive grid, no chart junk, the same font as the page."""
layout: dict[str, object] = {
"font": {"family": FONT_STACK, "size": 12, "color": INK_2},
"paper_bgcolor": "rgba(0,0,0,0)",
"plot_bgcolor": "rgba(0,0,0,0)",
"margin": {"l": 8, "r": 8, "t": 36, "b": 8},
"hoverlabel": {"bgcolor": INK, "font": {"color": "#FFFFFF", "family": FONT_STACK}},
"showlegend": False,
"hovermode": "x unified",
}
layout.update(overrides)
return layout
AXIS = {"showgrid": True, "gridcolor": LINE, "zeroline": False, "linecolor": LINE, "tickcolor": LINE, "ticks": ""}
PLOTLY_CONFIG = {"displayModeBar": False, "responsive": True}
+92
View File
@@ -0,0 +1,92 @@
# Мок электронной медицинской карты ЕМИАС (интеграция ЕМИАС/ЕГИСЗ — адаптер, SPEC §9). ТОЛЬКО СИНТЕТИКА.
# Ключ — синтетический СНИЛС из scripts/seed_demo.py; в рантайме превращается в токен через pseudo_id(), так что
# в чат-агента не попадают ни СНИЛС, ни ФИО: только клинические поля ниже.
#
# Поля:
# diagnoses — диагнозы МКБ-10 из карты (ставит врач; агент их не озвучивает как «диагноз», только учитывает)
# conditions — сопутствующие состояния и анамнез; `tags` сверяются с data/meds/drug_safety.yaml
# allergies — аллергии и непереносимости; `tags` сверяются с составом препаратов (вспомогательные вещества)
# other_medications — назначения других специалистов; `classes` сверяются со взаимодействиями
# pregnancy — для женщин: беременность/лактация (null — нет данных)
# Текущее психотропное назначение приходит из таблицы prescriptions (мок МИС, app/integrations/mis_mock.py).
records:
"900-000-013 34": # Андрей С., 34, ветеран СВО — герой демо
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
- {code: "T93.2", name: "Последствия осколочного ранения нижней конечности"}
conditions:
- {name: "Язвенная болезнь желудка в анамнезе (2019), ремиссия", tags: [ulcer_history]}
- {name: "Хронический болевой синдром после ранения", tags: [chronic_pain]}
allergies:
- {name: "Непереносимость лактозы", tags: [lactose]}
other_medications:
- {drug: "ибупрофен", how: "по требованию при боли", prescribed_by: "хирург", classes: [nsaid]}
pregnancy: null
"900-000-041 01": # Дмитрий В., 41, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
conditions:
- {name: "Артериальная гипертензия", tags: [hypertension]}
allergies: []
other_medications:
- {drug: "амлодипин", how: "ежедневно", prescribed_by: "терапевт", classes: [antihypertensive]}
pregnancy: null
"900-000-037 02": # Сергей П., 37, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
conditions:
- {name: "Мигрень", tags: [migraine]}
allergies: []
other_medications:
- {drug: "суматриптан", how: "при приступе мигрени", prescribed_by: "невролог", classes: [triptan]}
pregnancy: null
"900-000-052 03": # Николай Ф., 52, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F10.1", name: "Пагубное употребление алкоголя"}
conditions:
- {name: "Фибрилляция предсердий", tags: [atrial_fibrillation]}
allergies: []
other_medications:
- {drug: "варфарин", how: "ежедневно под контролем МНО", prescribed_by: "кардиолог", classes: [anticoagulant]}
pregnancy: null
"900-000-023 04": # Анна Л., 23
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
conditions: []
allergies: []
other_medications: []
pregnancy: {status: "не беременна, планирует беременность в течение года"}
"900-000-034 05": # Ирина К., 34
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
conditions: []
allergies: []
other_medications: []
pregnancy: null
"900-000-029 06": # Ольга Н., 29
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
- {code: "F41.1", name: "Генерализованное тревожное расстройство"}
conditions: []
allergies: []
other_medications: []
pregnancy: null
"900-000-045 07": # Павел Р., 45
diagnoses:
- {code: "F33.4", name: "Рекуррентное депрессивное расстройство, ремиссия"}
conditions:
- {name: "Эпилепсия, ремиссия 5 лет", tags: [epilepsy]}
allergies: []
other_medications:
- {drug: "вальпроевая кислота", how: "ежедневно", prescribed_by: "невролог", classes: [anticonvulsant]}
pregnancy: null
+146
View File
@@ -0,0 +1,146 @@
# Структурированный справочник безопасности препаратов для чат-агента (состав, противопоказания, взаимодействия).
# ПРОТОТИП: краткий пересказ инструкций по медицинскому применению (ГРЛС), сверить с действующей инструкцией и
# утвердить у психиатра (#8). Состав (вспомогательные вещества) зависит от производителя — здесь пример для
# формы из назначения; агент обязан это оговаривать.
#
# Теги совпадают с data/emias/records.yaml:
# condition tags (conditions[].tags) → cautions[].when_condition
# allergy tags (allergies[].tags) → composition.excipient_tags
# medicine class (other_medications.classes) → interactions[].with_class
# Детерминированная проверка — app/llm/agent/drug_safety.py; LLM только пересказывает её результат.
common_ssri: &ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены (риск серотонинового синдрома)."
cautions:
- when_condition: ulcer_history
severity: high
text: "Язвенная болезнь в анамнезе: СИОЗС повышают риск желудочно-кишечных кровотечений, особенно вместе с НПВС или антикоагулянтами."
- when_condition: epilepsy
severity: medium
text: "Эпилепсия: препарат применяют с осторожностью, при появлении судорог его отменяет врач."
- when_condition: bipolar
severity: high
text: "Биполярное расстройство: возможен переход в маниакальную фазу, нужен контроль врача."
interactions:
- with_class: nsaid
severity: high
text: "НПВС (ибупрофен, диклофенак, кеторолак и др.) вместе с СИОЗС повышают риск кровотечений, прежде всего желудочно-кишечных."
- with_class: anticoagulant
severity: high
text: "Антикоагулянты (варфарин и др.): возрастает риск кровотечений, нужен более частый контроль свёртываемости."
- with_class: triptan
severity: medium
text: "Триптаны (суматриптан и др.): описан риск серотонинового синдрома, о сочетании должен знать врач."
- with_class: serotonergic_opioid
severity: high
text: "Трамадол и другие серотонинергические опиоиды: риск серотонинового синдрома и судорог."
- with_class: maoi
severity: high
text: "Ингибиторы МАО: сочетание противопоказано."
- with_class: alcohol
severity: medium
text: "Алкоголь во время лечения не рекомендуется: ухудшает сон и настроение, усиливает сонливость и головокружение."
pregnancy: "При беременности или её планировании решение о продолжении лечения принимает врач."
drugs:
сертралин:
aliases: [sertraline, золофт, zoloft]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Одновременный приём пимозида."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "сертралина гидрохлорид"
excipients: [кальция гидрофосфат, целлюлоза микрокристаллическая, гипролоза, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80]
excipient_tags: []
note: "Пример состава оригинального препарата; у дженериков состав может отличаться (в части из них есть лактоза) — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению сертралина (пересказ для прототипа)"
пароксетин:
aliases: [paroxetine, паксил, paxil, рексетин]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Одновременный приём тиоридазина или пимозида."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "пароксетина гидрохлорида гемигидрат"
excipients: [кальция гидрофосфата дигидрат, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80]
excipient_tags: []
note: "Пример состава; у дженериков возможны отличия (в том числе лактоза) — сверить с инструкцией на упаковке."
pregnancy: "При беременности или её планировании решение принимает врач; для пароксетина в инструкции отдельно указан риск для плода в первом триместре."
source: "ГРЛС, инструкция по медицинскому применению пароксетина (пересказ для прототипа)"
эсциталопрам:
aliases: [escitalopram, ципралекс, cipralex, селектра]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Удлинение интервала QT (врождённое или приобретённое) и приём препаратов, удлиняющих QT."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "эсциталопрама оксалат"
excipients: [целлюлоза микрокристаллическая, кремния диоксид коллоидный, тальк, кроскармеллоза натрия, магния стеарат, гипромеллоза, макрогол, титана диоксид]
excipient_tags: []
note: "Пример состава оригинального препарата; у дженериков состав может отличаться — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению эсциталопрама (пересказ для прототипа)"
флуоксетин:
aliases: [fluoxetine, прозак, prozac, профлузак]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 5 недель после отмены флуоксетина перед их назначением."
- "Одновременный приём тиоридазина или пимозида."
composition:
form: "капсулы"
active: "флуоксетина гидрохлорид"
excipients: [крахмал кукурузный, диметикон, желатин (оболочка капсулы), титана диоксид, красители]
excipient_tags: [gelatin]
note: "Пример состава; у разных производителей отличается (в части капсул есть лактоза) — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению флуоксетина (пересказ для прототипа)"
ибупрофен:
aliases: [ibuprofen, нурофен, nurofen, миг]
class: "нестероидный противовоспалительный препарат (НПВС)"
classes: [nsaid]
contraindications:
- "Эрозивно-язвенные поражения желудка и кишечника в стадии обострения, желудочно-кишечное кровотечение."
- "Повышенная чувствительность к компонентам, «аспириновая» астма."
cautions:
- when_condition: ulcer_history
severity: high
text: "Язвенная болезнь в анамнезе: НПВС применяют с осторожностью из-за риска обострения и кровотечения."
interactions:
- with_class: ssri
severity: high
text: "Вместе с антидепрессантами группы СИОЗС повышается риск кровотечений."
- with_class: anticoagulant
severity: high
text: "С антикоагулянтами возрастает риск кровотечений."
composition:
form: "таблетки, покрытые оболочкой"
active: "ибупрофен"
excipients: [кроскармеллоза натрия, натрия лаурилсульфат, натрия цитрат, стеариновая кислота, кремния диоксид коллоидный, сахароза, тальк]
excipient_tags: []
note: "Состав зависит от производителя — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению ибупрофена (пересказ для прототипа)"
# Классы препаратов из назначений психиатра (для проверки «другой препарат × текущая терапия»).
drug_classes:
ssri: [сертралин, пароксетин, эсциталопрам, флуоксетин, циталопрам, флувоксамин]
# Подписи тегов непереносимостей для ответа пациенту.
allergy_labels:
lactose: "лактоза"
gelatin: "желатин"
+57
View File
@@ -0,0 +1,57 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: эсциталопрам
aliases: [escitalopram, ципралекс, cipralex, селектра]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частая нежелательная реакция — тошнота; также возможны головная боль, бессонница или сонливость,
повышенная потливость, диарея, усталость.
Побочные эффекты чаще возникают в первые 1–2 недели лечения и обычно ослабевают или проходят при продолжении
приёма.
Возможны нарушения половой функции: снижение влечения, нарушение эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Улучшение обычно отмечается через 2–4 недели регулярного приёма.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Фармакодинамического взаимодействия с алкоголем не ожидается, однако, как и с другими препаратами этой группы,
их одновременный приём не рекомендован.
Алкоголь сам по себе ухудшает сон и настроение.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу,
раздражительность, тошноту, головную боль.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение 1–2 недель или дольше,
по решению и под контролем врача.
Изменять схему лечения может только лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают один раз в сутки независимо от приёма пищи.
Таблетки принимают ежедневно примерно в одно и то же время.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО и пимозидом противопоказан.
Не рекомендуется совместное применение с препаратами, удлиняющими интервал QT.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: головокружение, дрожь, возбуждение, сонливость, тошнота, рвота, учащённое
сердцебиение, нарушения сердечного ритма.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+52
View File
@@ -0,0 +1,52 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: флуоксетин
aliases: [fluoxetine, прозак, prozac]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: тошнота, бессонница, головная боль, нервозность и беспокойство,
снижение аппетита, повышенная потливость, диарея.
Многие из них, в том числе тошнота, выражены в начале лечения и обычно уменьшаются в течение первых недель.
Возможны нарушения половой функции: снижение влечения, нарушение эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Улучшение обычно отмечается через 2–4 недели регулярного приёма, иногда позже.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Флуоксетин выводится из организма медленно, поэтому симптомы отмены могут появиться не сразу, а через
несколько дней или недель.
При прекращении приёма возможны головокружение, нарушения сна, тревога, раздражительность, тошнота.
Препарат не следует отменять самостоятельно: решение об отмене принимает лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают внутрь, обычно один раз в сутки утром, независимо от приёма пищи.
Капсулы проглатывают, запивая водой.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО, пимозидом и тиоридазином противопоказан; из-за медленного выведения
флуоксетина интервал перед назначением ингибиторов МАО составляет не менее 5 недель.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: тошнота, рвота, возбуждение, учащённое сердцебиение, судороги.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+50
View File
@@ -0,0 +1,50 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: миртазапин
aliases: [mirtazapine, ремерон, remeron, каликста]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: сонливость, повышение аппетита и массы тела, сухость во рту,
головокружение, головная боль, отёки.
Сонливость чаще выражена в первые недели лечения и обычно уменьшается при продолжении приёма.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно: первые признаки улучшения обычно появляются через 1–2 недели,
полный эффект — через 2–4 недели регулярного приёма.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Миртазапин усиливает угнетающее действие алкоголя на нервную систему, поэтому во время лечения от алкоголя
следует воздержаться.
Сочетание с алкоголем усиливает сонливость и замедляет реакцию.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма может вызвать симптомы отмены: головокружение, тревогу, возбуждение, головную боль,
тошноту.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат обычно принимают один раз в сутки вечером, перед сном, независимо от приёма пищи.
Таблетку проглатывают, не разжёвывая, запивая водой.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО противопоказан.
Миртазапин усиливает седативное действие бензодиазепинов и других успокоительных средств.
Совместное применение с другими серотонинергическими средствами повышает риск серотонинового синдрома.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: выраженная сонливость, дезориентация, учащённое сердцебиение.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+56
View File
@@ -0,0 +1,56 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: пароксетин
aliases: [paroxetine, паксил, paxil, рексетин]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: тошнота, сонливость или бессонница, повышенная потливость, дрожь,
слабость, сухость во рту, запор, снижение аппетита.
Частота и выраженность многих побочных эффектов, в том числе тошноты, уменьшаются при продолжении лечения,
обычно в первые 1–2 недели.
Возможны нарушения половой функции: снижение влечения, нарушение эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Улучшение обычно отмечается через 2–4 недели регулярного приёма, а в первые дни самочувствие может не меняться.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Хотя пароксетин не усиливает вредное действие алкоголя на психомоторные функции, их одновременный приём
не рекомендован.
Алкоголь сам по себе ухудшает сон и настроение.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма часто вызывает синдром отмены: головокружение, нарушения сна и яркие сновидения,
тревогу, раздражительность, тошноту, потливость, ощущение покалывания.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение нескольких недель, по решению
и под контролем врача.
Изменять схему лечения может только лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают один раз в сутки утром, во время завтрака.
Таблетку проглатывают целиком, не разжёвывая, запивая водой.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО, тиоридазином и пимозидом противопоказан.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
Пароксетин влияет на обмен ряда препаратов в печени, поэтому о любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: тошнота, рвота, дрожь, расширение зрачков, сухость во рту, раздражительность,
сонливость.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+49
View File
@@ -0,0 +1,49 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Празозин зарегистрирован как средство, снижающее артериальное давление; при ночных кошмарах его назначает
# врач вне зарегистрированных показаний — сроки эффекта в инструкции для этого случая не указаны.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: празозин
aliases: [prazosin, минипресс, minipress]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: головокружение, сонливость, головная боль, слабость, сердцебиение,
тошнота.
После первого приёма возможно резкое снижение давления при вставании с головокружением и даже обмороком
(«эффект первой дозы»).
Головокружение обычно уменьшается при продолжении лечения; о стойких побочных эффектах нужно сообщить врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Фармакологические свойства»"
text: >-
Действие на артериальное давление начинается через несколько часов после приёма.
Срок, через который ожидается эффект в вашем случае, определяет лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Алкоголь усиливает снижение давления, головокружение и сонливость.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Препарат не следует прекращать или возобновлять самостоятельно.
После перерыва в приёме снова возможен «эффект первой дозы», поэтому порядок возобновления определяет врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Первый приём рекомендуется вечером перед сном, в положении лёжа.
Вставать с постели после приёма следует медленно, особенно ночью.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с другими средствами, снижающими давление, и с препаратами для лечения эректильной
дисфункции усиливает снижение давления.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: резкое снижение давления, выраженная сонливость, слабость, обморок.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+57
View File
@@ -0,0 +1,57 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: сертралин
aliases: [sertraline, золофт, zoloft]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции в начале лечения: тошнота, диарея, сухость во рту, бессонница или
сонливость, головная боль и головокружение.
Тошнота и другие нежелательные явления начала лечения обычно ослабевают или проходят в течение 1–2 недель.
Возможны нарушения половой функции: снижение влечения, задержка эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Первые признаки улучшения могут появиться в течение первой недели, а полный эффект обычно достигается через
2–4 недели регулярного приёма.
В первые дни, пока эффект ещё не развился, самочувствие может не улучшаться, а побочные эффекты ощущаются
сильнее.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Хотя в исследованиях сертралин не усиливал действие алкоголя, их одновременный приём не рекомендован.
Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты, например сонливость и
головокружение.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу, тошноту,
ощущение покалывания.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача.
Изменять схему лечения может только лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают внутрь один раз в сутки, утром или вечером, независимо от приёма пищи.
Таблетки принимают ежедневно примерно в одно и то же время.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО и пимозидом противопоказан.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
С антикоагулянтами и обезболивающими из группы НПВП возрастает риск кровотечений.
О любых других лекарствах, включая безрецептурные, нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: сонливость, тошнота, рвота, учащённое сердцебиение, дрожь, возбуждение,
головокружение.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+165
View File
@@ -0,0 +1,165 @@
{
"_comment": "СИНТЕТИКА. Назначения-образцы для mis_mock (заглушка МИС/ЕГИСЗ, SPEC §9). Ключ — токен пациента; источник первого уровня — таблица prescriptions клинической БД, этот файл — запасной. Персоны: docs/product/01_demo_persona.md.",
"patients": [
{
"persona": "andrey",
"tokens": [
"tok-andrey"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "50 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "dmitry",
"tokens": [
"tok-dmitry"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "100 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "sergey",
"tokens": [
"tok-sergey"
],
"prescriptions": [
{
"drug": "пароксетин",
"dose": "20 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром во время завтрака",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "nikolay",
"tokens": [
"tok-nikolay"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "50 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "anna",
"tokens": [
"tok-anna"
],
"prescriptions": [
{
"drug": "флуоксетин",
"dose": "20 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "irina",
"tokens": [
"tok-irina"
],
"prescriptions": [
{
"drug": "эсциталопрам",
"dose": "10 мг",
"schedule": {
"times": [
"09:00"
]
},
"instructions": "утром",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "olga",
"tokens": [
"tok-olga"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "50 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "pavel",
"tokens": [
"tok-pavel"
],
"prescriptions": [
{
"drug": "эсциталопрам",
"dose": "10 мг",
"schedule": {
"times": [
"09:00"
]
},
"instructions": "утром",
"start_date": null,
"end_date": null
}
]
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{"day": 0, "note": "День 0, визит к психиатру. Врач подключил Андрея (код из seed). Андрей пишет боту: согласие, знакомство, короткий скрининг (PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C), вопрос про оружие. Баллы пациенту не показываются.", "actions": [{"text": "Здравствуйте"}, {"press": "Согласен(на)"}, {"press": "Начнём"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Нет"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Ни разу"}, {"press": "2–4 раза в месяц"}, {"press": "3–4"}, {"press": "Никогда"}, {"press": "Нет"}, {"expect_color": "green"}]}
{"day": 1, "note": "День 1. 08:00 — напоминание о сертралине → «Принял». 20:00 — чек-ин: настроение, сон, кошмары, алкоголь.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}, {"expect_color": "green"}]}
{"day": 2, "note": "День 2. «Подташнивает» → ответ по инструкции к препарату (ГРЛС): частая побочка, проходит за 1–2 недели.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "подташнивает с утра"}, {"expect_color": "green"}]}
{"day": 3, "note": "День 3. Тошнота снова → короткая поддержка, инструкция не повторяется.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "опять тошнит"}, {"expect_color": "green"}]}
{"day": 4, "note": "День 4. Побочка третий день подряд → правило side_effect_3d, у врача жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "тошнота не проходит"}, {"expect_color": "yellow"}]}
{"day": 5, "note": "День 5. «Пропущу» → «Решил(а) бросить»; вечером «брошу» → отсроченный эффект, не бросать без врача; алерт quit_intent (жёлтый).", "actions": [{"advance_to": "08:00"}, {"press": "Пропущу"}, {"press": "Решил(а) бросить"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "от таблеток только хуже, брошу"}, {"expect_color": "yellow"}]}
{"day": 6, "note": "День 6. Врач позвонил Андрею. ВЕДУЩИЙ: до вечернего чек-ина подтвердите (ack) алерты Андрея на дашборде — тогда он снова зелёный.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "6–8 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "врач позвонил, продолжаю"}]}
{"day": 7, "note": "День 7. 10:00 — плановые шкалы по расписанию модулей: скрининг C-SSRS (все «Нет»), затем PHQ-9 = 13 (п.9 = 0). PCL-5 по расписанию раз в 14 дней.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "10:00"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Ни разу"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "6–8 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}]}
{"day": 8, "note": "День 8. Сон < 4 ч, кошмары, 1–2 порции алкоголя.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "1–2 порции"}, {"text": "снится одно и то же"}]}
{"day": 9, "note": "День 9. Сон < 4 ч, алкоголь больше.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Больше"}, {"press": "Пропустить"}]}
{"day": 10, "note": "День 10. Сон < 4 ч три ночи + алкоголь три дня → информация о взаимодействии с сертралином и кнопка «Записаться к врачу раньше»; у врача жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Больше"}, {"text": "без пива не могу уснуть"}, {"press": "Записаться к врачу раньше"}, {"expect_color": "yellow"}]}
{"day": 11, "note": "День 11. Алерты открыты, правило молчит (без повторов).", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "1–2 порции"}, {"press": "Пропустить"}, {"expect_color": "yellow"}]}
{"day": 12, "note": "День 12. Настроение 3 второй день подряд, у врача по-прежнему жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}, {"expect_color": "yellow"}]}
{"day": 13, "note": "День 13. «Всё бессмысленно» → скрининг C-SSRS кнопками; настроение ≤ 3 три дня → внеплановый PHQ-9 = 18, п.9 = 2 → кризисный скрипт, план безопасности, алерт дежурному врачу — красный.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "2"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "всё бессмысленно, устал от всего"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"expect_color": "red"}]}
+5 -3
View File
@@ -9,7 +9,8 @@
- таблицы -> markdown; у таблицы-продолжения на новой странице повторяется шапка, - таблицы -> markdown; у таблицы-продолжения на новой странице повторяется шапка,
строка, разорванная границей страницы, склеивается; строка, разорванная границей страницы, склеивается;
- колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются; - колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются;
- маркеры страниц `--- стр. N ---` для ссылок на источник; - маркеры страниц `--- стр. N ---` для ссылок на источник; абзац, перетёкший на новую
страницу, склеивается, а место перехода помечается `⟦стр. N⟧`;
- текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p<N>.txt - текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p<N>.txt
(ручная транскрипция) и вставляется в конец своей страницы; (ручная транскрипция) и вставляется в конец своей страницы;
- если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом - если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом
@@ -211,9 +212,10 @@ def convert(pdf_path: Path) -> str:
if not text: if not text:
continue continue
if first_on_page and prev and prev[0] == "text" and text[0].islower(): if first_on_page and prev and prev[0] == "text" and text[0].islower():
# абзац перетёк на новую страницу: дописываем его хвост к началу # абзац перетёк на новую страницу: дописываем его хвост к началу, а место
# перехода помечаем ⟦стр. N⟧ — чанкер по метке ставит верную страницу в цитату
head, _, rest = text.partition("\n") head, _, rest = text.partition("\n")
prev[1] = prev[1] + " " + head prev[1] = prev[1] + f" ⟦стр. {num}⟧ " + head
if rest: if rest:
blocks.append(["text", rest]) blocks.append(["text", rest])
else: else:
+1056
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
"""Test-wide isolation: a developer's local `.env` (e.g. OPENROUTER_API_KEY) must never make tests call a real
LLM provider — that costs money and makes results depend on the network. Agent tests pass explicit
providers/settings; everything else runs with the chat agent off."""
import pytest
from app.config import get_settings
@pytest.fixture(autouse=True)
def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CHAT_AGENT", "off")
monkeypatch.setenv("OPENROUTER_API_KEY", "")
monkeypatch.setenv("LLM_PROVIDER", "mock")
get_settings.cache_clear()
yield
get_settings.cache_clear()
+157
View File
@@ -0,0 +1,157 @@
"""API #12: dialog endpoints (events, outbox, history) and demo controls (clock, reset, personas)."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
import httpx
import pytest
from app.api import demo
from app.config import Settings
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Reply
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
DAY0 = datetime(2026, 10, 5, 9, 0)
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_identity_session() as ids:
await identity_repo.create_patient(
ids,
token=TOKEN,
display_name="Андрей С.",
snils="11122233344",
phone="+79990000001",
clinic_id="c",
doctor_id="doc-smirnova",
now=clock.now(),
birth_year=1992,
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran"],
therapy_start=clock.today() - timedelta(days=13),
)
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def test_events_reach_engine_and_unknown_patient_is_404(client: httpx.AsyncClient) -> None:
r = await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "Привет"})
assert r.status_code == 200 and r.json()["replies"]
r = await client.post("/api/v1/dialog/" + "f" * 32 + "/events", json={"type": "text", "text": "x"})
assert r.status_code == 404
async def test_outbox_drains_once(client: httpx.AsyncClient) -> None:
await integrations.outbox.push(TOKEN, [Reply(text="Пора принять лекарство")])
first = (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]
assert [x["text"] for x in first] == ["Пора принять лекарство"]
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_outbox_found_behind_wrapping_outbox(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
inner = MemoryOutbox()
class Wrapper: # like TelegramOutbox: unbound chats fall back to the console queue
fallback = inner
async def push(self, token: str, replies: list[Reply]) -> None:
await inner.push(token, replies)
monkeypatch.setattr(integrations, "outbox", Wrapper())
await inner.push(TOKEN, [Reply(text="x")])
assert len((await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]) == 1
async def test_history_oldest_first_with_limit(client: httpx.AsyncClient) -> None:
async with session.get_clinical_session() as s:
for i in range(5):
await repo.add_message(
s,
token=TOKEN,
direction="in" if i % 2 == 0 else "out",
text=ScrubbedText(f"m{i}"),
ts=clock.now() + timedelta(minutes=i),
)
rows = (await client.get(f"/api/v1/dialog/{TOKEN}/history", params={"limit": 3})).json()
assert [m["text"] for m in rows] == ["m2", "m3", "m4"]
assert [m["direction"] for m in rows] == ["in", "out", "in"]
async def test_history_stores_scrubbed_text(client: httpx.AsyncClient) -> None:
await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "мой телефон +7 999 123-45-67"})
texts = " ".join(m["text"] for m in (await client.get(f"/api/v1/dialog/{TOKEN}/history")).json())
assert "123-45-67" not in texts and "999" not in texts
async def test_clock_advance_and_reset(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(demo, "SEED_MODULE", "scripts.no_such_seed_module")
before = (await client.get("/api/v1/demo/clock")).json()
r = (await client.post("/api/v1/demo/clock/advance", json={"hours": 2})).json()
assert r["virtual"] is True and r["offset_days"] == pytest.approx(before["offset_days"] + 2 / 24, abs=1e-3)
await integrations.outbox.push(TOKEN, [Reply(text="stale")])
assert (await client.post("/api/v1/demo/reset")).json() == {"status": "ok", "seed": "skipped"}
assert (await client.get("/api/v1/demo/clock")).json()["offset_days"] == pytest.approx(0, abs=1e-6)
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_reset_calls_seed_module(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
import sys
import types
calls: list[str] = []
fake = types.ModuleType("fake_seed_demo")
async def reset() -> None:
calls.append("reset")
fake.reset = reset # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "fake_seed_demo", fake)
monkeypatch.setattr(demo, "SEED_MODULE", "fake_seed_demo")
monkeypatch.setattr(demo.util, "find_spec", lambda name: object())
assert (await client.post("/api/v1/demo/reset")).json()["seed"] == "reset"
assert calls == ["reset"]
async def test_personas_from_scenario_files(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(demo, "get_settings", lambda: Settings(_env_file=None, data_dir=tmp_path))
assert (await client.get("/api/v1/demo/personas")).json() == []
(tmp_path / "synthetic").mkdir()
(tmp_path / "synthetic" / "andrey.jsonl").write_text('{"day": 0, "actions": []}\n', encoding="utf-8")
personas = (await client.get("/api/v1/demo/personas")).json()
assert personas == [
{"token": TOKEN, "display_name": "Андрей С.", "therapy_day": 13, "scenario": "data/synthetic/andrey.jsonl"}
]
def test_persona_slug() -> None:
assert demo.persona_slug("Андрей С.") == "andrey"
assert demo.persona_slug("Юлия Щ.") == "yuliya"
assert demo.persona_slug("") == ""
+288
View File
@@ -0,0 +1,288 @@
"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from sqlalchemy import select
from app.api import doctor_views
from app.config import Settings
from app.core import clock
from app.core.privacy import pseudo_id
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.bootstrap import init_db
from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
DOCTOR = "doc-smirnova"
OTHER = "doc-other"
DAY0 = datetime(2026, 10, 5, 9, 0)
# Synthetic patients: token suffix → (name, snils, phone, doctor)
ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef")
PEOPLE = {
ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR),
DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR),
OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR),
PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR),
IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR),
STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER),
}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0 + timedelta(days=13))
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await seed()
# No lifespan: the scheduler and the Telegram bot are not started.
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def seed() -> None:
now = clock.now()
start = (DAY0).date()
async with session.get_identity_session() as ids:
for token, (name, snils, phone, doctor) in PEOPLE.items():
await identity_repo.create_patient(
ids,
token=token,
display_name=name,
snils=snils,
phone=phone,
clinic_id="clinic-1",
doctor_id=doctor,
now=now,
birth_year=1990,
sex="m",
)
async with session.get_clinical_session() as s:
for token, (_, _, _, doctor) in PEOPLE.items():
await repo.upsert_profile(
s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m"
)
s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await s.flush()
rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()}
# Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan.
await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now)
await repo.add_scale_result(
s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7)
)
await repo.add_scale_result(
s,
token=ANDREY,
scale="phq9",
total=18,
severity="mod_severe",
complete=True,
ts=now - timedelta(hours=2),
scheduled=False,
item9=2,
)
await repo.add_scale_result(
s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13)
)
for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]):
await repo.upsert_checkin(
s,
token=ANDREY,
day=now.date() - timedelta(days=2 - i),
now=now,
mood=mood,
sleep_bucket=sleep,
alcohol_bucket=alcohol,
med_taken=True,
)
s.add(
SafetyPlan(
token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now
)
)
# Dmitry: high alert (yellow). Olga: info only (grey).
await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now)
await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now)
# Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens.
for d in range(7):
planned = now - timedelta(days=d, hours=1)
s.add(
Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken")
)
s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken"))
await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5)
# Foreign doctor's patient with an open alert: must never show up for DOCTOR.
await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now)
await repo.set_dialog_state(
s,
token=ANDREY,
state="crisis",
data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}},
now=now,
)
async def audit_rows(action: str) -> list[AuditLog]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all())
async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None:
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA]
assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"]
andrey, pavel, irina = rows[0], rows[3], rows[4]
assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36
assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1
assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up")
assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13
assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale
assert rows[1]["last_event"] == "PCL-5 ≥ 50"
factors = " ".join(andrey["discontinuation_risk"]["factors"])
assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors
assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01)
assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0
assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent
assert pavel["discontinuation_risk"]["level"] == "medium"
assert len(await audit_rows("view_patient_list")) == 1
async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})
assert r.status_code == 200
card = r.json()
metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]}
assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics
assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics
assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics
assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"]
assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red"
assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]}
assert card["summary"]
rows = await audit_rows("view_patient")
assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY
async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER})
assert r.status_code == 403
assert await audit_rows("view_patient") == []
assert len(await audit_rows("view_patient_denied")) == 1
assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404
async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json()
assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient
assert all(a["patient_name"] for a in alerts)
top = max(a["id"] for a in alerts)
assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == []
assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure
async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json()
acute = next(a for a in alerts if a["token"] == ANDREY)
assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403
r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR})
assert r.status_code == 200
out = r.json()
assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С."
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, ANDREY)
profile = await s.get(PatientProfile, ANDREY)
assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False
assert profile is not None and profile.status_color == "green"
assert isinstance(integrations.outbox, MemoryOutbox)
assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console
assert len(await audit_rows("ack_alert")) == 1
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green"
async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None:
body = {
"doctor_id": DOCTOR,
"display_name": "Сергей Т.",
"birth_year": 1988,
"sex": "m",
"snils": "123-456-789 01",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00", "20:00"],
"instructions": "после еды",
"modules": ["veteran", "depression"],
}
r = await client.post("/api/v1/patients/connect", json=body)
assert r.status_code == 200
out = r.json()
assert out["token"] == pseudo_id("12345678901")
assert out["deep_link"] == f"/console?code={out['code']}"
assert "12345678901" not in r.text and "123-456-789" not in r.text
async with session.get_identity_session() as ids:
assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, out["token"])
rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"]))
assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR
assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"]
assert len(await audit_rows("connect_patient")) == 1
card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json()
assert card["prescriptions"][0]["instructions"] == "после еды"
assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green"
assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409
async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None:
texts = [
(await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text,
(await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text,
(await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text,
]
for _, snils, phone, _ in PEOPLE.values():
for text in texts:
assert snils not in text and phone not in text and phone[1:] not in text
def test_risk_index_all_factors_capped() -> None:
now = datetime(2026, 10, 18, 12, 0).astimezone()
days = [now.date() - timedelta(days=i) for i in range(4)]
checkins = [
SimpleNamespace(
day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more"
)
for d in days
]
data = doctor_views.PatientData(
profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type]
checkins=checkins, # type: ignore[arg-type]
results=[],
doses=[],
prescriptions=[],
alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item]
)
risk = doctor_views.discontinuation_risk(data, now)
assert risk.score == 100 and risk.level == "high"
assert risk.factors == [
"пропуски приёма 4/7 дн.",
"побочные эффекты 4 дн. подряд",
"хотел бросить (день 13)",
"не разобран сигнал «хочет бросить»",
"настроение ≤ 3 4 дн. подряд",
"алкоголь 4 дн. из 7",
]
+12 -5
View File
@@ -40,7 +40,7 @@ async def _patient_with_code(code: str) -> None:
await identity_repo.create_connect_code(s, code=code, token=TOKEN, 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_nothing(client: AsyncClient) -> None: async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json() first = (await client.post("/api/v1/auth/anonymous")).json()
second = (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 first["mode"] == "anonymous" and first["display_name"] is None
@@ -48,7 +48,9 @@ async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient)
async with session.get_identity_session() as s: async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
assert await repo.get_profile(s, first["token"]) is None # 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: async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
@@ -199,10 +201,10 @@ async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClie
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values()) assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
# An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules. # Stage 1 is the same for everyone: it does not wait for a module (it sets them itself).
monkeypatch.setenv("INTAKE_DEMO", "1") monkeypatch.setenv("INTAKE_DEMO", "1")
token = "c" * 32 token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def press(button: str | None = None) -> list[str]: async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"} event = {"type": "button", "button": button} if button else {"type": "start"}
@@ -213,3 +215,8 @@ async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypat
await press() await press()
await press("consent:yes") await press("consent:yes")
assert any(":phq9_1:" in b for b in await press("age:adult")) 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]
+5 -6
View File
@@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None:
await session.configure() await session.configure()
def test_api_fixture_routes() -> None: def test_api_fixtures_and_clock() -> None:
# Routes read the DB since #12 (covered by tests/test_api_*.py); fixtures stay as schema-valid reference data.
from app.api import fixtures
assert fixtures.card("tok-andrey") is not None and fixtures.PATIENTS[0].status_color == "red"
with TestClient(app) as c: with TestClient(app) as c:
patients = c.get("/api/v1/doctors/doc-smirnova/patients").json()
assert patients[0]["status_color"] == "red"
card = c.get("/api/v1/patients/tok-andrey", params={"doctor_id": "doc-smirnova"}).json()
assert card["safety_plan"] and card["series"]
assert c.post("/api/v1/dialog/tok-andrey/events", json={"type": "text", "text": "ok"}).status_code == 200
assert c.get("/api/v1/demo/clock").json()["virtual"] is True assert c.get("/api/v1/demo/clock").json()["virtual"] is True
+126
View File
@@ -0,0 +1,126 @@
"""Dashboard HTTP client: paths, parsing via the frozen schemas, graceful errors (no tracebacks to the UI)."""
import json
import httpx
import pytest
from app.api import fixtures
from app.api.schemas import ConnectPatientIn
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
def _client(handler) -> ApiClient:
return ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(handler))
def _json(models) -> list | dict:
if isinstance(models, list):
return [m.model_dump(mode="json") for m in models]
return models.model_dump(mode="json")
def test_base_url_gets_api_prefix_once():
assert ApiClient("http://x:8000/").base_url == "http://x:8000/api/v1"
assert ApiClient("http://x:8000/api/v1").base_url == "http://x:8000/api/v1"
def test_doctor_id_from_env(monkeypatch):
monkeypatch.setenv("DOCTOR_ID", "doc-other")
assert ApiClient("http://x").doctor_id == "doc-other"
monkeypatch.delenv("DOCTOR_ID")
assert ApiClient("http://x").doctor_id == "doc-smirnova"
def test_patients_and_card_are_parsed():
seen: list[str] = []
def handler(req: httpx.Request) -> httpx.Response:
seen.append(str(req.url))
if req.url.path.endswith("/patients"):
return httpx.Response(200, json=_json(fixtures.PATIENTS))
return httpx.Response(200, json=_json(fixtures.andrey_card()))
c = _client(handler)
patients = c.patients()
assert patients[0].display_name == "Андрей С."
card = c.patient_card("tok-andrey")
assert card.patient.status_color == "red"
assert seen[0] == "http://api.test/api/v1/doctors/doc-smirnova/patients"
assert seen[1] == "http://api.test/api/v1/patients/tok-andrey?doctor_id=doc-smirnova"
def test_alerts_params_and_ack_body():
captured: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
if req.method == "POST":
captured["body"] = json.loads(req.content)
return httpx.Response(200, json=_json(fixtures.all_alerts()[0]))
captured["params"] = dict(req.url.params)
return httpx.Response(200, json=_json(fixtures.all_alerts()))
c = _client(handler)
assert len(c.alerts(since_id=2, open_only=True)) == 4
assert captured["params"] == {"since_id": "2", "open_only": "true"}
c.ack(3)
assert captured["body"] == {"doctor_id": "doc-smirnova"}
def test_connect_posts_payload():
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
assert body["drug"] == "сертралин" and body["times"] == ["08:00"]
return httpx.Response(200, json={"token": "t", "code": "DEMO-1", "deep_link": "https://t.me/x?start=DEMO-1"})
body = ConnectPatientIn(
doctor_id="doc-smirnova",
display_name="Андрей С.",
birth_year=1992,
sex="m",
snils="000-000-000 00",
drug="сертралин",
dose="50 мг",
)
assert _client(handler).connect(body).code == "DEMO-1"
def test_connection_error_becomes_unavailable():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=req)
with pytest.raises(ApiUnavailable) as exc:
_client(handler).patients()
assert "API недоступен" in exc.value.message
def test_timeout_becomes_unavailable():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=req)
with pytest.raises(ApiUnavailable):
_client(handler).alerts()
@pytest.mark.parametrize(("status", "fragment"), [(404, "Не найдено"), (500, "500"), (422, "422")])
def test_http_errors(status, fragment):
c = _client(lambda req: httpx.Response(status, json={"detail": "x"}))
with pytest.raises(ApiError) as exc:
c.patient_card("tok-x")
assert fragment in exc.value.message
assert exc.value.status == status
assert not isinstance(exc.value, ApiUnavailable)
def test_bad_payload_is_api_error():
c = _client(lambda req: httpx.Response(200, json=[{"token": "only"}]))
with pytest.raises(ApiError, match="неожиданный"):
c.patients()
c = _client(lambda req: httpx.Response(200, content=b"<html>"))
with pytest.raises(ApiError):
c.patients()
def test_clock_failure_is_none():
c = _client(lambda req: httpx.Response(503))
assert c.clock() is None
+419
View File
@@ -0,0 +1,419 @@
"""Demo console (#17): scenario parser, press resolver, clock math, playback runner, API client."""
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
import pytest
# Streamlit runs pages with `dashboard/` on sys.path and they import `console`; tests mirror that.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dashboard"))
from console.api import ApiError, ConsoleApi, personas_or_fallback # noqa: E402
from console.chatlog import ChatLog, normalize_reply # noqa: E402
from console.playback import ( # noqa: E402
PlaybackRunner,
PlaybackState,
hours_until,
parse_clock_now,
planned_date,
resolve_press,
target_datetime,
)
from console.scenario import ( # noqa: E402
Action,
default_scenario_path,
load_scenario,
parse_action,
parse_scenario,
persona_slug,
)
MSK = timezone(timedelta(hours=3))
def _jsonl(*rows: Any) -> str:
return "\n".join(r if isinstance(r, str) else json.dumps(r, ensure_ascii=False) for r in rows)
def _reply(text: str, *labels_data: tuple[str, str], kind: str = "normal") -> dict[str, Any]:
return {"text": text, "kind": kind, "buttons": [[{"label": lab, "data": d} for lab, d in labels_data]]}
# ------------------------------------------------------------------------------------------------ scenario
def test_parse_scenario_valid_sorted_and_normalized():
sc = parse_scenario(
_jsonl(
{"day": 2, "note": "тошнота", "actions": [{"advance_to": "8:00"}, {"press": "Принял"}]},
"",
"# comment",
{"day": 0, "actions": [{"start": None}, {"text": "привет"}, {"expect_color": "GREEN"}]},
)
)
assert sc.errors == []
assert sc.day_numbers == [0, 2]
assert sc.day(2).actions == (Action("advance_to", "08:00"), Action("press", "Принял"))
assert sc.day(0).actions[0] == Action("start", "")
assert sc.day(0).actions[2] == Action("expect_color", "green")
assert sc.day(2).note == "тошнота"
def test_parse_scenario_reports_and_skips_bad_parts():
sc = parse_scenario(
_jsonl(
"{not json",
[1, 2],
{"day": -1, "actions": []},
{"day": 1, "actions": [{"advance_to": "25:00"}, {"press": "Да"}, {"fly": "x"}, {"a": 1, "b": 2}]},
{"day": 1, "actions": [{"text": "дубль"}]},
{"day": 3, "actions": "oops"},
{"day": 4, "actions": [{"expect_color": "purple"}, {"text": " "}]},
)
)
assert sc.day_numbers == [1, 4]
assert sc.day(1).actions == (Action("press", "Да"),)
assert sc.day(4).actions == ()
assert len(sc.errors) == 10
assert any("строка 1" in e and "не JSON" in e for e in sc.errors)
assert any("повторяется" in e for e in sc.errors)
assert any("ЧЧ:ММ" in e for e in sc.errors)
@pytest.mark.parametrize("raw", [{"press": 5}, "press", {"advance_to": "8"}, {}])
def test_parse_action_rejects(raw):
with pytest.raises(ValueError):
parse_action(raw)
def test_load_scenario_missing_and_present(tmp_path):
missing = load_scenario("nope.jsonl", root=tmp_path)
assert missing.days == [] and "не найден" in missing.errors[0]
(tmp_path / "s.jsonl").write_text(_jsonl({"day": 5, "actions": [{"text": "брошу"}]}), encoding="utf-8")
assert load_scenario("s.jsonl", root=tmp_path).day_numbers == [5]
# ------------------------------------------------------------------------------------------------ press resolver
def test_resolve_press_newest_reply_first_and_exact_beats_substring():
replies = [
_reply("вчера", ("Принял", "med:taken:1")),
_reply("сегодня", ("Принял позже", "med:later:2"), ("Принял", "med:taken:2")),
]
assert resolve_press(replies, "принял") == ("med:taken:2", "Принял")
assert resolve_press(replies, "позже") == ("med:later:2", "Принял позже")
def test_resolve_press_window_normalization_and_miss():
old = _reply("старое", ("Ещё вопрос", "q:1"))
filler = [_reply(f"r{i}") for i in range(10)]
assert resolve_press([old, *filler], "еще") is None # outside the 10-reply window
assert resolve_press([old, *filler[:3]], " ЕЩЕ вопрос ") == ("q:1", "Ещё вопрос")
assert resolve_press([old], "нет такой") is None
assert resolve_press([old], "") is None
# ------------------------------------------------------------------------------------------------ clock math
def test_target_datetime_next_occurrence_without_planned_date():
now = datetime(2026, 10, 5, 7, 0, tzinfo=MSK)
assert target_datetime(now, "08:00", None) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
late = datetime(2026, 10, 5, 21, 0, tzinfo=MSK)
assert target_datetime(late, "08:00", None) == datetime(2026, 10, 6, 8, 0, tzinfo=MSK)
# Just reached (virtual time ticked a bit past): stays today, no rollover.
just = datetime(2026, 10, 5, 8, 2, tzinfo=MSK)
assert target_datetime(just, "08:00", None) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
def test_target_datetime_with_planned_date_and_hours_until():
now = datetime(2026, 10, 5, 20, 30, tzinfo=MSK)
target = target_datetime(now, "08:00", date(2026, 10, 6))
assert hours_until(now, target) == pytest.approx(11.5)
assert hours_until(now, now - timedelta(minutes=3)) == 0.0
assert hours_until(now, now - timedelta(hours=2)) is None
def test_planned_date_chains_days_and_ignores_stale_dates():
dates = {0: date(2026, 10, 5), 2: date(2026, 10, 7)}
assert planned_date(dates, 5, today=date(2026, 10, 7)) == date(2026, 10, 10)
assert planned_date(dates, 2, today=date(2026, 10, 7)) == date(2026, 10, 7)
assert planned_date({}, 3, today=date(2026, 10, 7)) is None
# Presenter moved the clock manually past the chained date → fall back to "next occurrence".
assert planned_date(dates, 3, today=date(2026, 10, 12)) is None
def test_parse_clock_now():
assert parse_clock_now({"now": "2026-10-05T08:00:00+03:00"}) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
with pytest.raises(ValueError):
parse_clock_now({})
# ------------------------------------------------------------------------------------------------ chat log
def test_chatlog_normalizes_and_tracks_active_keyboard():
assert normalize_reply({"text": None, "buttons": [[{"label": "x"}], "bad"]}) == {
"dir": "out",
"text": "",
"kind": "normal",
"buttons": [],
}
chat = ChatLog()
chat.add_replies([_reply("a", ("Да", "y")), {"text": "b"}])
chat.add_in("Да", pressed=True)
assert chat.last_buttons_index() == 0
assert [r["text"] for r in chat.recent_replies(1)] == ["b"]
chat.add_history([{"direction": "in", "text": "hi"}, {"direction": "out", "text": "yo", "kind": "info"}])
assert chat.entries[-1]["kind"] == "info" and chat.entries[-2]["dir"] == "in"
# ------------------------------------------------------------------------------------------------ runner
class FakeApi:
"""Demo backend double: a virtual clock with scheduled pushes and scripted dialog answers."""
def __init__(self, now: datetime):
self.now = now
self.calls: list[tuple] = []
self.scheduled: list[tuple[datetime, dict[str, Any]]] = []
self.box: list[dict[str, Any]] = []
self.color = "green"
self.fail_on: str | None = None
self.answers: dict[str, list[dict[str, Any]]] = {}
def _maybe_fail(self, what: str) -> None:
if self.fail_on == what:
raise ApiError(f"{what}: HTTP 500")
def push_at(self, hhmm: str, day: date, reply: dict[str, Any]) -> None:
h, m = map(int, hhmm.split(":"))
self.scheduled.append((datetime(day.year, day.month, day.day, h, m, tzinfo=MSK), reply))
def clock(self) -> dict[str, Any]:
self._maybe_fail("clock")
return {"now": self.now.isoformat(), "virtual": True, "offset_days": 0}
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]:
self._maybe_fail("advance")
self.calls.append(("advance", round(hours, 3)))
self.now += timedelta(days=days, hours=hours)
due = [(t, r) for t, r in self.scheduled if t <= self.now]
self.scheduled = [(t, r) for t, r in self.scheduled if t > self.now]
self.box.extend(r for _, r in due)
return self.clock()
def send_event(self, token, type_, *, text=None, button=None, params=None):
self._maybe_fail(type_)
self.calls.append((type_, text or button or params))
return self.answers.get(text or button or type_, [{"text": "ок"}])
def outbox(self, token):
out, self.box = self.box, []
return out
def patients(self, doctor):
self._maybe_fail("patients")
return [{"token": "tok-other", "status_color": "red"}, {"token": "tok-andrey", "status_color": self.color}]
def _runner(api: FakeApi, state: PlaybackState | None = None, chat: ChatLog | None = None) -> PlaybackRunner:
sleeps: list[float] = []
r = PlaybackRunner(api, "tok-andrey", chat or ChatLog(), state or PlaybackState(), delay=0.5, sleep=sleeps.append)
r.sleeps = sleeps # type: ignore[attr-defined]
return r
SCENARIO = parse_scenario(
_jsonl(
{"day": 1, "note": "приём", "actions": [{"advance_to": "08:00"}, {"press": "Принял"}]},
{
"day": 2,
"actions": [
{"advance_to": "08:00"},
{"press": "принял"},
{"advance_to": "20:00"},
{"text": "подташнивает с утра"},
{"expect_color": "yellow"},
],
},
{"day": 5, "actions": [{"advance_to": "20:00"}, {"text": "брошу"}]},
)
)
def test_runner_plays_day_in_order_with_dynamic_button_data():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
api.push_at("08:00", date(2026, 10, 6), _reply("Пора принять сертралин", ("Принял", "med:taken:42")))
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 1))
assert api.calls == [("advance", 11.0), ("button", "med:taken:42")]
assert [log.status for log in logs] == ["info", "ok", "ok", "ok"]
assert runner.state.done == [1] and runner.state.dates[1] == date(2026, 10, 6)
assert runner.chat.entries[1] == {"dir": "in", "text": "Принял", "pressed": True}
assert runner.sleeps == [0.5] # one pause between two steps
def test_runner_chains_next_day_date_and_soft_color_check():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
api.push_at("08:00", date(2026, 10, 6), _reply("доза 1", ("Принял", "med:taken:1")))
api.push_at("08:00", date(2026, 10, 7), _reply("доза 2", ("Принял", "med:taken:2")))
runner = _runner(api)
list(runner.play_day(SCENARIO, 1))
logs = list(runner.play_day(SCENARIO, 2))
assert api.calls[2:] == [
("advance", 24.0),
("button", "med:taken:2"),
("advance", 12.0),
("text", "подташнивает с утра"),
]
color_log = logs[-2]
assert color_log.status == "warn" and "green" in color_log.message
assert runner.state.done == [1, 2] and runner.state.failed is None
def test_scroll_to_plays_all_unplayed_days_fast():
api = FakeApi(datetime(2026, 10, 5, 7, 0, tzinfo=MSK))
api.color = "yellow"
for d in (5, 6):
api.push_at("08:00", date(2026, 10, d), _reply("доза", ("Принял", f"med:taken:{d}")))
runner = _runner(api)
runner.delay = 0
logs = list(runner.scroll_to(SCENARIO, 5))
assert runner.state.done == [1, 2, 5]
# Day 1 lands on today 08:00 (nothing played yet), day 5 is chained: 5 − 2 = 3 days after day 2.
assert runner.state.dates == {1: date(2026, 10, 5), 2: date(2026, 10, 6), 5: date(2026, 10, 9)}
assert all(log.status != "fail" for log in logs)
assert runner.sleeps == []
assert list(runner.scroll_to(SCENARIO, 5))[0].status == "info" # nothing left to scroll
def test_missing_button_pauses_and_resume_can_skip_or_retry():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK)) # no reminder scheduled → button never shows up
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 1))
assert logs[-1].status == "fail" and "не найдена" in logs[-1].message
assert runner.state.failed is not None and (runner.state.failed.day, runner.state.failed.step) == (1, 1)
assert runner.state.done == []
assert runner.sleeps == [0.5] + [runner.retry_wait] * runner.press_retries # step pause, then retries
api.box.append(_reply("доза", ("Принял", "med:taken:7")))
retry = list(runner.resume(SCENARIO, skip=False))
assert retry[-1].status == "ok" and ("button", "med:taken:7") in api.calls
assert runner.state.failed is None and runner.state.done == [1]
def test_resume_skip_continues_until_target_day():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
runner = _runner(api)
runner.delay = 0
list(runner.scroll_to(SCENARIO, 2)) # fails on day 1 «Принял»
assert runner.state.failed.until == 2
list(runner.resume(SCENARIO, skip=True)) # skip the press, play day 1 rest and day 2 → fails on day 2 press
assert runner.state.done == [1]
assert (runner.state.failed.day, runner.state.failed.step) == (2, 1)
def test_api_errors_never_escape_the_runner():
api = FakeApi(datetime(2026, 10, 5, 19, 0, tzinfo=MSK))
api.fail_on = "text"
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 5))
assert logs[-1].status == "fail" and "HTTP 500" in logs[-1].message
assert runner.state.failed is not None
api.fail_on = "clock"
logs = list(_runner(api).play_day(SCENARIO, 5))
assert logs[-1].status == "fail" and "часы" in logs[-1].message
api.fail_on = "patients"
color_only = parse_scenario(_jsonl({"day": 0, "actions": [{"expect_color": "green"}]}))
logs = list(_runner(api).play_day(color_only, 0))
assert logs[1].status == "warn" and logs[-1].status == "ok"
def test_past_target_is_skipped_with_warning():
api = FakeApi(datetime(2026, 10, 6, 21, 0, tzinfo=MSK))
state = PlaybackState(dates={5: date(2026, 10, 6)})
logs = list(_runner(api, state).play_day(SCENARIO, 5)) # replaying day 5 after 20:00 the same evening
assert logs[1].status == "warn" and "уже прошло" in logs[1].message
assert ("text", "брошу") in api.calls and not any(c[0] == "advance" for c in api.calls)
def test_play_unknown_day_is_info():
logs = list(_runner(FakeApi(datetime(2026, 10, 5, 8, 0, tzinfo=MSK))).play_day(SCENARIO, 42))
assert [log.status for log in logs] == ["info"]
def test_start_action_sends_code():
api = FakeApi(datetime(2026, 10, 5, 8, 0, tzinfo=MSK))
sc = parse_scenario(_jsonl({"day": 0, "actions": [{"start": "DEMO-0001"}, {"start": ""}]}))
list(_runner(api).play_day(sc, 0))
assert api.calls == [("start", {"code": "DEMO-0001"}), ("start", {})]
# ------------------------------------------------------------------------------------------------ http client
def _client(handler) -> ConsoleApi:
return ConsoleApi("http://api", client=httpx.Client(transport=httpx.MockTransport(handler)))
def test_console_api_paths_and_bodies():
seen: list[tuple[str, str, Any]] = []
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content) if req.content else None
seen.append((req.method, req.url.path, body))
if req.url.path.endswith("/events"):
return httpx.Response(200, json={"replies": [{"text": "hi"}]})
if req.url.path.endswith("/outbox"):
return httpx.Response(200, json={"replies": []})
return httpx.Response(200, json={"now": "2026-10-05T08:00:00+03:00"})
api = _client(handler)
assert api.send_event("tok-andrey", "button", button="med:taken:1") == [{"text": "hi"}]
assert api.outbox("tok-andrey") == []
api.advance(hours=1.5)
assert seen == [
("POST", "/api/v1/dialog/tok-andrey/events", {"type": "button", "params": {}, "button": "med:taken:1"}),
("GET", "/api/v1/dialog/tok-andrey/outbox", None),
("POST", "/api/v1/demo/clock/advance", {"days": 0, "hours": 1.5}),
]
def test_console_api_errors_become_api_error_and_personas_fall_back(monkeypatch):
def down(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
api = _client(down)
with pytest.raises(ApiError, match="недоступен"):
api.clock()
assert api.health() is False
monkeypatch.setenv("DEMO_PERSONA_TOKEN", "tok-andrey")
personas, error = personas_or_fallback(api)
assert personas[0]["token"] == "tok-andrey" and error
api = _client(lambda req: httpx.Response(500))
with pytest.raises(ApiError, match="HTTP 500"):
api.outbox("t")
ok = _client(lambda req: httpx.Response(200, json=[{"token": "tok-andrey", "display_name": "Андрей С."}]))
personas, error = personas_or_fallback(ok)
assert error is None and personas[0]["display_name"] == "Андрей С."
def test_personas_fall_back_to_doctor_patients_with_hero_first():
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path.endswith("/demo/personas"):
return httpx.Response(200, json=[])
return httpx.Response(
200, json=[{"token": "h1", "display_name": "Сергей П."}, {"token": "h2", "display_name": "Андрей С."}]
)
personas, error = personas_or_fallback(_client(handler))
assert [p["token"] for p in personas] == ["h2", "h1"] and "пациенты врача" in error
def test_scenario_path_guessed_from_name_or_token(tmp_path):
(tmp_path / "data" / "synthetic").mkdir(parents=True)
(tmp_path / "data" / "synthetic" / "andrey.jsonl").write_text("", encoding="utf-8")
assert persona_slug("Андрей С.") == "andrey" and persona_slug("Наталья Ф.") == "natalya"
assert default_scenario_path({"token": "h", "display_name": "Андрей С."}, tmp_path) == "data/synthetic/andrey.jsonl"
assert default_scenario_path({"token": "tok-andrey"}, tmp_path) == "data/synthetic/andrey.jsonl"
assert default_scenario_path({"token": "x", "display_name": "Ольга Н."}, tmp_path) is None
assert default_scenario_path({"token": "x", "scenario": "s.jsonl"}, tmp_path) == "s.jsonl"
+64
View File
@@ -0,0 +1,64 @@
"""Demo console pages (#17) render without crashing even when the API is down (robustness of the live demo)."""
from pathlib import Path
import pytest
from streamlit.testing.v1 import AppTest
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
@pytest.fixture
def api_down(monkeypatch):
monkeypatch.setenv("API_BASE_URL", "http://127.0.0.1:9") # nothing listens on the discard port
monkeypatch.setenv("DEMO_PERSONA_TOKEN", "tok-andrey")
monkeypatch.delenv("PATIENT_WEB_URL", raising=False)
def _page(name: str) -> AppTest:
return AppTest.from_file(str(PAGES / name), default_timeout=30)
def test_console_page_survives_api_down_and_button_clicks(api_down, tmp_path):
scenario = tmp_path / "s.jsonl"
scenario.write_text('{"day": 1, "note": "утро", "actions": [{"advance_to": "08:00"}]}\n', encoding="utf-8")
at = _page("2_Демо-консоль.py")
at.session_state["console_scenario_path:tok-andrey"] = str(scenario)
at.run()
assert not at.exception
assert any("Часы недоступны" in w.value for w in at.warning)
labels = [b.label for b in at.button]
assert {"+1 час", "Следующий день", "🚀 Начать", "▶ Проиграть день 1"} <= set(labels)
at.button(key="console-clock-d1").click().run() # «Следующий день» with the API down
assert not at.exception
at.button(key="console-chat-start").click().run()
assert not at.exception
next(b for b in at.button if b.label == "▶ Проиграть день 1").click().run()
assert not at.exception
state = at.session_state["console_playback:tok-andrey"]
assert state.failed is not None and state.done == [] # playback paused, page alive
at.run()
assert not at.exception
assert any("Повторить шаг" in b.label for b in at.button), [b.label for b in at.button]
def test_console_page_without_scenario_file(api_down):
at = _page("2_Демо-консоль.py")
at.session_state["console_scenario_path:tok-andrey"] = "data/synthetic/__missing__.jsonl"
at.run()
assert not at.exception
assert any("Сценарий не загружен" in i.value for i in at.info)
def test_split_screen_survives_api_down_and_uses_iframe_when_configured(api_down, monkeypatch):
at = _page("3_Сплит-экран.py")
at.run()
assert not at.exception
assert any("Алерты недоступны" in w.value for w in at.warning)
monkeypatch.setenv("PATIENT_WEB_URL", "http://localhost:5173")
at = _page("3_Сплит-экран.py")
at.run()
assert not at.exception
assert any("http://localhost:5173" in c.value for c in at.caption)
+227
View File
@@ -0,0 +1,227 @@
"""Pure dashboard logic: ordering, labels, dose strip, scales config, SNILS, chart builders, page discovery."""
import random
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from app.api import fixtures
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, SeriesPoint
from dashboard.app import discover_pages, slugify
from dashboard.components import charts, ui
from dashboard.components import helpers as h
TZ = ZoneInfo("Europe/Moscow")
def _risk(score: int, level: str = "low", factors: list[str] | None = None) -> DiscontinuationRisk:
return DiscontinuationRisk(score=score, level=level, factors=factors or [])
def test_sort_red_yellow_grey_green_then_risk():
items = list(reversed(fixtures.PATIENTS))
items.append(fixtures.PATIENTS[1].model_copy(update={"token": "g", "display_name": "Г", "status_color": "grey"}))
out = h.sort_patients(items)
colors = [p.status_color for p in out]
assert colors == sorted(colors, key=h.STATUS_ORDER.__getitem__)
assert out[0].token == "tok-andrey"
yellows = [p for p in out if p.status_color == "yellow"]
assert yellows[0].display_name == "Сергей П." # highest discontinuation risk among yellow
assert [p.discontinuation_risk.score for p in yellows] == sorted(
(p.discontinuation_risk.score for p in yellows), reverse=True
)
def test_filter_and_kpis():
items = fixtures.PATIENTS
assert {p.status_color for p in h.filter_patients(items, "yellow")} == {"yellow"}
assert [p.display_name for p in h.filter_patients(items, query="андр")] == ["Андрей С."]
assert len(h.filter_patients(items, query="пароксетин")) == 1
k = h.kpis(items)
assert (k.total, k.red, k.yellow, k.green, k.grey, k.high_risk) == (8, 1, 4, 3, 0, 1)
def test_trend_arrows():
assert h.trend_view("up").text == "↑" and h.trend_view("up").tone == "red"
assert h.trend_view("down").text == "↓" and h.trend_view("down").tone == "green"
assert h.trend_view("flat").text == "→"
assert h.trend_view("unknown").tone == "grey"
def test_risk_badge_and_tooltip():
v = h.risk_view(_risk(70, "high", ["пропуски 3 из 7", "нет ответа 2 дня"]))
assert v.text == "Высокий · 70" and v.tone == "red"
assert "• пропуски 3 из 7\n• нет ответа 2 дня" in v.hint
assert h.risk_view(_risk(35, "medium")).tone == "yellow"
assert "значимых факторов нет" in h.risk_view(_risk(5)).hint
def test_adherence_thresholds():
assert h.adherence_view(None).text == "—"
assert h.adherence_view(1.0).tone == "green"
assert h.adherence_view(0.8).tone == "green"
assert h.adherence_view(0.7).tone == "yellow"
assert h.adherence_view(0.57) == h.View("57%", "red")
def test_level_tone_matches_adr006():
assert h.level_tone("acute") == "red"
assert h.level_tone("high") == h.level_tone("moderate") == "yellow"
assert h.level_tone("info") == "grey"
def test_severity_bands_come_from_config():
bands = h.severity_bands("phq9")
assert [b.lo for b in bands] == [0, 5, 10, 15, 20]
assert h.severity_label("phq9", 18) == "Умеренно-тяжёлая"
assert h.severity_label("pcl5", 44) == "Вероятное ПТСР"
assert h.severity_label("pcl5", None) == ""
assert h.severity_bands("no_such_scale") == ()
def test_last_and_prev():
card = fixtures.andrey_card()
assert h.last_and_prev(card.series, "phq9") == (18, 13)
assert h.last_and_prev(card.series, "gad7") == (None, None)
def test_dose_grid_fills_gaps_and_counts():
d0 = datetime(2026, 9, 21, 8, tzinfo=TZ)
doses = [
DoseOut(planned_at=d0, drug="сертралин", status="taken"),
DoseOut(planned_at=d0 + timedelta(hours=12), drug="сертралин", status="missed"),
DoseOut(planned_at=d0 + timedelta(days=2), drug="сертралин", status="skipped"),
DoseOut(planned_at=d0 + timedelta(days=3), drug="сертралин", status="pending"),
]
g = h.dose_grid(doses)
assert g.days == [date(2026, 9, 21) + timedelta(days=i) for i in range(4)]
assert g.slots == ["08:00", "20:00"]
assert g.cells[("20:00", date(2026, 9, 21))] == "missed"
assert ("08:00", date(2026, 9, 22)) not in g.cells # no dose planned that day → empty cell
assert g.counts == {"taken": 1, "missed": 1, "skipped": 1, "pending": 1}
assert g.adherence == 1 / 3 # pending is not counted
def test_dose_grid_empty():
g = h.dose_grid([])
assert g.days == [] and g.adherence is None
def test_dose_grid_converts_to_moscow_time():
utc = datetime(2026, 9, 21, 5, tzinfo=ZoneInfo("UTC"))
g = h.dose_grid([DoseOut(planned_at=utc, drug="x", status="taken")])
assert g.slots == ["08:00"]
def _alert(i: int, level: str = "moderate", acked: bool = False, hours: int = 0) -> AlertOut:
ts = datetime(2026, 10, 3, 9, tzinfo=TZ) + timedelta(hours=hours)
return AlertOut(
id=i,
token="t",
patient_name="П",
ts=ts,
level=level,
color=h.level_tone(level),
code="c",
reason="r",
acked_by="d" if acked else None,
acked_at=ts if acked else None,
)
def test_alert_ordering_new_and_local_acks():
alerts = [_alert(1, "moderate", hours=5), _alert(2, "acute", hours=1), _alert(3, "high", acked=True, hours=9)]
assert [a.id for a in h.sort_alerts(alerts)] == [2, 1, 3]
assert [a.id for a in h.split_new(alerts, {1})] == [2, 3]
at = datetime(2026, 10, 3, 22, tzinfo=TZ)
out = h.apply_local_acks(alerts, {2: ("doc-smirnova", at)})
assert out[1].acked_by == "doc-smirnova" and out[1].acked_at == at
assert out[0].acked_at is None
def test_russian_formatting():
assert [h.age_label(n) for n in (21, 23, 34, 41, 45, 11, 112)] == [
"21 год",
"23 года",
"34 года",
"41 год",
"45 лет",
"11 лет",
"112 лет",
]
assert h.age_label(0) == "возраст не указан"
assert h.absolute_link("/console?code=X", "http://h:8501/") == "http://h:8501/console?code=X"
assert h.absolute_link("https://t.me/b?start=X", "http://h") == "https://t.me/b?start=X"
today = date(2026, 10, 3)
assert h.visit_label(date(2026, 10, 18), today) == "визит 18 окт · через 15 дней"
assert h.visit_label(date(2026, 10, 4), today) == "визит 4 окт · через 1 день"
assert h.visit_label(today, today) == "визит сегодня"
assert "просрочен" in h.visit_label(date(2026, 10, 1), today)
assert h.visit_label(None, today) == "визит не назначен"
assert h.when_label(datetime(2026, 10, 3, 21, tzinfo=TZ), today) == "сегодня, 21:00"
def test_snils_checksum():
assert h.snils_checksum("112233445") == 95 # well-known valid example 112-233-445 95
assert h.is_valid_snils("112-233-445 95")
assert not h.is_valid_snils("112-233-445 96")
rng = random.Random(1)
for _ in range(50):
s = h.synthetic_snils(rng)
assert s.startswith("000-") and h.is_valid_snils(s)
def test_ui_row_escapes_and_shows_values():
p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"})
html = ui.patient_row(p, detailed=True)
assert "<script>" not in html and "&lt;script&gt;" in html
assert "Андрей С." in html and "день терапии 13" in html and "↑" in html
def test_home_fragments_escape_api_values():
payload = '<img src=x onerror="alert(1)">'
p = fixtures.PATIENTS[0].model_copy(
update={
"display_name": payload,
"drug": payload,
"last_event": payload,
"discontinuation_risk": _risk(70, "high", [payload]),
}
)
for html in (ui.patient_row(p), ui.patient_row(p, detailed=True), ui.workspace_header(payload, payload)):
assert "<img" not in html
assert "&lt;img" in html
def test_scale_figure_marks_unscheduled_and_bands():
card = fixtures.andrey_card()
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
assert fig is not None
trace = fig.data[0]
assert list(trace.marker.symbol) == ["circle", "circle", "diamond-open"]
assert len(fig.layout.shapes) == 5 # severity bands from config/scales/phq9.yaml
assert charts.scale_figure([], "phq9", "PHQ-9") is None
def test_wellbeing_figure_small_multiples():
card = fixtures.andrey_card()
fig = charts.wellbeing_figure(card.series)
assert fig is not None and len(fig.data) == 3
assert charts.wellbeing_figure([SeriesPoint(day=date(2026, 1, 1), metric="phq9", value=3)]) is None
def test_page_discovery_orders_and_sections(tmp_path: Path):
for name in ("1_Пациенты.py", "1b_Подключить_пациента.py", "2_Демо-консоль.py", "3_Сплит-экран.py", "helper.py"):
(tmp_path / name).write_text("", encoding="utf-8")
(tmp_path / "4_Что-то.py").write_text('NAV_ICON = ":material/star:"\nNAV_SECTION = "Прочее"\n', encoding="utf-8")
specs = discover_pages(tmp_path)
assert [s.title for s in specs] == ["Пациенты", "Подключить пациента", "Демо-консоль", "Сплит-экран", "Что-то"]
assert [s.section for s in specs][:4] == ["Кабинет врача", "Кабинет врача", "Демо", "Демо"]
assert specs[4].icon == ":material/star:" and specs[4].section == "Прочее"
assert specs[2].url_path == "demo-konsol"
assert slugify("Сплит-экран") == "split-ekran"
def test_real_pages_dir_has_doctor_pages():
titles = [s.title for s in discover_pages()]
assert titles[:2] == ["Пациенты", "Подключить пациента"]
+228
View File
@@ -0,0 +1,228 @@
"""Streamlit AppTest smoke tests of the doctor pages against an in-memory API (fixtures over httpx.MockTransport)."""
from pathlib import Path
import httpx
import pytest
from streamlit.testing.v1 import AppTest
from app.api import fixtures
from dashboard.api_client import ApiClient
from dashboard.components import state
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
LIST_PAGE = str(PAGES / "1_Пациенты.py")
CONNECT_PAGE = str(PAGES / "1b_Подключить_пациента.py")
def _dump(obj):
return [o.model_dump(mode="json") for o in obj] if isinstance(obj, list) else obj.model_dump(mode="json")
class FakeApi:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
self.extra_alerts: list = []
self.patients = list(fixtures.PATIENTS)
def __call__(self, req: httpx.Request) -> httpx.Response:
path = req.url.path.removeprefix("/api/v1")
self.calls.append((req.method, path))
if path == "/doctors/doc-smirnova/patients":
return httpx.Response(200, json=_dump(self.patients))
if path.startswith("/patients/tok-"):
card = fixtures.card(path.rsplit("/", 1)[1])
return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404)
if path == "/doctors/doc-smirnova/alerts":
return httpx.Response(200, json=_dump(self.extra_alerts + fixtures.all_alerts()))
if path.startswith("/alerts/") and path.endswith("/ack"):
alert = next(a for a in fixtures.all_alerts() if a.id == int(path.split("/")[2]))
acked = alert.model_copy(update={"acked_by": "doc-smirnova", "acked_at": alert.ts})
return httpx.Response(200, json=_dump(acked))
if path == "/patients/connect":
return httpx.Response(
200, json={"token": "tok-new", "code": "DEMO-0001", "deep_link": "https://t.me/b?start=DEMO-0001"}
)
if path == "/demo/clock":
return httpx.Response(200, json={"now": "2026-10-03T21:05:00+03:00", "virtual": True, "offset_days": 0})
return httpx.Response(404)
@pytest.fixture
def api(monkeypatch) -> FakeApi:
fake = FakeApi()
client = ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(fake))
monkeypatch.setattr(state, "client", lambda: client)
return fake
def _html(at: AppTest) -> str:
return "\n".join(str(el.proto.body) for el in at.get("html"))
def test_list_page_renders_sorted_patients(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
html = _html(at)
assert "Кабинет д-ра Смирновой Е. А." in html
assert "Пациенты под наблюдением" in html
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
assert "Высокий · 70" in html # discontinuation-risk badge
open_buttons = [b for b in at.button if b.label == "Открыть"]
assert len(open_buttons) == len(fixtures.PATIENTS)
def test_list_filter_and_open_card(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Срочно").run()
at.text_input(key="patient_query").set_value("андр").run()
at.toggle(key="patient_details").set_value(True).run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
at.button(key="open-tok-andrey").click().run()
assert not at.exception
assert at.query_params["patient"] in ("tok-andrey", ["tok-andrey"])
html = _html(at)
assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html
assert "Решения принимает врач" in html
assert ("GET", "/patients/tok-andrey") in api.calls
next(b for b in at.button if b.label == "Все пациенты").click().run()
assert not at.exception
assert not at.query_params.get("patient")
assert len([b for b in at.button if b.label == "Открыть"]) == 1 # the selected filter is retained
assert at.text_input(key="patient_query").value == "андр"
assert at.toggle(key="patient_details").value is True
@pytest.mark.parametrize("choice,expected", [("Внимание", 4), ("Без уведомлений", 3), ("Все пациенты", 8)])
def test_home_status_filters(api, choice, expected):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value(choice).run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == expected
assert f"Показано {expected} из 8 пациентов" in _html(at)
def test_home_search_empty_result_and_reset(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.text_input(key="patient_query").set_value(" ПАРОКСЕТИН ").run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Сергей П." in _html(at)
at.segmented_control(key="status_filter").set_value("Без уведомлений").run()
assert "Пациенты не найдены" in _html(at)
next(b for b in at.button if b.label == "Сбросить фильтры").click().run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 8
assert at.text_input(key="patient_query").value == ""
def test_home_empty_cabinet_keeps_connect_action(api):
api.patients = []
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "Пациентов пока нет" in _html(at)
assert "Показано 0 из 0 пациентов" in _html(at)
assert any(b.label == "Подключить пациента" for b in at.button)
assert not any(b.label == "Открыть" for b in at.button)
def test_home_details_preserve_clinical_information(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert "PHQ-9" in _html(at) and "PCL-5" in _html(at)
at.toggle(key="patient_details").set_value(True).run()
assert not at.exception
html = _html(at)
assert "сертралин 50 мг" in html
assert "PHQ-9 п.9 = 2 — кризисный протокол" in html
assert "пропуски 3 из 7" in html
def test_home_info_only_patient_and_missing_scores(api):
api.patients = [
fixtures.PATIENTS[0].model_copy(
update={
"status_color": "grey",
"top_alert_level": "info",
"phq9_last": None,
"pcl5_last": None,
"adherence_7d": None,
"open_alerts": 1,
}
)
]
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Нет ответа").run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Из них только инфо: 1" in _html(at)
assert 'mt-dim">—' in _html(at)
def test_card_ack_posts_and_marks_alert(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20)
at.query_params["patient"] = "tok-andrey"
at.run()
assert not at.exception
assert at.button(key="ack-3").label == "Подтвердить"
at.button(key="ack-3").click().run()
assert ("POST", "/alerts/3/ack") in api.calls
assert not [b for b in at.button if b.key == "ack-3"] # acked alert has no button any more
def test_api_down_shows_banner(monkeypatch):
def refuse(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=req)
client = ApiClient("http://api.test", transport=httpx.MockTransport(refuse))
monkeypatch.setattr(state, "client", lambda: client)
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "API недоступен" in _html(at)
assert any(b.label == "Повторить загрузку" for b in at.button)
assert "Всего пациентов" not in _html(at) # failed loading is not a zero-patient cabinet
def test_connect_flow_shows_code(api):
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
assert not at.exception
at.button[0].click().run() # «Заполнить демо-данными»
at.checkbox(key="cp_consent").check().run()
assert at.text_input(key="cp_name").value == "Андрей С."
submit = next(b for b in at.button if b.label == "Подключить и получить код")
submit.click().run()
assert not at.exception
assert ("POST", "/patients/connect") in api.calls
assert "DEMO-0001" in _html(at)
assert at.get("image") # QR image
def test_connect_requires_consent(api):
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
at.text_input(key="cp_name").set_value("Пётр К.").run()
next(b for b in at.button if b.label == "Подключить и получить код").click().run()
assert at.error and "согласие" in at.error[0].value
assert ("POST", "/patients/connect") not in api.calls
def test_app_entry_navigation_and_acute_banner(api):
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
assert not at.exception
html = _html(at)
assert "Кризисный сигнал · Андрей С." in html # open acute alert pinned above every page
assert "Кабинет д-ра Смирновой Е. А." in html # default page = patient list
assert "Прототип на синтетических данных" in html
assert ("GET", "/doctors/doc-smirnova/alerts") in api.calls
def test_new_alert_toasts_once(api):
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
assert not at.toast # alerts open at session start are not "new"
new = fixtures.all_alerts()[0].model_copy(
update={"id": 10, "token": "tok-sergey", "patient_name": "Сергей П.", "reason": "C-SSRS: намерение"}
)
api.extra_alerts.append(new)
at.run()
assert not at.exception
assert any("Сергей П." in t.value for t in at.toast)
assert "Кризисный сигнал · Сергей П." in _html(at)
at.run()
assert not any("Сергей П." in t.value for t in at.toast)
+188
View File
@@ -0,0 +1,188 @@
"""Knowledge base in the dialog (#64): «Вопрос о лечении» menu, questions in idle free text, routing order
(safety → check-in signals → drug question → guidelines → support)."""
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
import pytest
from app.config import Settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.knowledge import QUESTION_STATE, load_knowledge_texts, looks_like_question
from app.integrations.base import DrugSection, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.med_qa import load_med_qa_texts
from app.llm.mock import MockTasks
from app.llm.rag import fallback_text
from app.llm.rag import qa as rag_qa
from app.llm.texts import render_phrase
from app.services.contracts import NoopMonitoring
TOKEN = "k" * 32
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 7, 8, 0)
SUPPORT = render_phrase("support", {})
KB = load_knowledge_texts()
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
text = "Улучшение обычно отмечается через 2–4 недели регулярного приёма."
return [DrugSection(inn=inn, section="onset", text=text, source="синтетика")]
@pytest.fixture
async def engine(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Engine]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
monkeypatch.setattr(rag_qa, "get_gateway", lambda: LLMGateway(MockTasks()))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
yield Engine(gateway=LLMGateway(MockTasks()), monitoring=NoopMonitoring())
clock.reset()
await session.configure()
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def say(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def joined(replies: list[Reply]) -> str:
return "\n".join(r.text for r in replies)
# --- question detection --------------------------------------------------------------------------------
@pytest.mark.parametrize(
("text", "expected"),
[
("что такое флэшбек", True),
("Можно ли вылечить ПТСР", True),
("кошмары снятся, это нормально?", True),
("сегодня 6 из 10", False),
("спал плохо", False),
("какой-то день", False), # "как" only as a whole word at the start
("как дела?", False), # small talk
("Привет! Как ты?", False),
("[NAME], что такое ПТСР", True), # scrub placeholder before the question
],
)
def test_looks_like_question(text: str, expected: bool) -> None:
assert looks_like_question(text) is expected
# --- menu «Вопрос о лечении» ------------------------------------------------------------------------------
async def test_menu_has_knowledge_button(engine: Engine) -> None:
r = await engine.handle(TOKEN, Event(type="start"))
assert "menu:kb_question" in buttons(r)
async def test_menu_question_answers_from_guidelines(engine: Engine) -> None:
r = await press(engine, "menu:kb_question")
assert joined(r) == KB.prompt
assert (await state())[0] == QUESTION_STATE
r = await say(engine, "что такое флэшбек?")
text = joined(r)
assert "Флэшбек (flashback" in text
assert "Источник: КР ПТСР 2023, стр. 2" in text
assert rag_qa.load_rag_texts()["patient"].doctor_note in text
assert "menu:kb_question" in buttons(r) # back to the menu
assert (await state())[0] == "idle"
async def test_menu_question_without_answer_sends_to_doctor(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, "какой рецепт борща")
assert fallback_text("patient") in joined(r)
assert (await state())[0] == "idle"
@pytest.mark.parametrize("question", ["можно увеличить дозу?", "можно пить полтаблетки?"])
async def test_dose_question_redirected_to_doctor(engine: Engine, question: str) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, question)
assert joined(r).startswith(KB.dose_redirect)
assert "клинических рекомендациях" not in joined(r)
async def test_button_instead_of_question_leaves_the_flow(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await press(engine, "menu:contact_doctor")
assert "врач" in joined(r)
assert (await state())[0] == "idle"
async def test_scrubbed_question_still_answered(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, "Иван Петров, 89161234567, что такое ПТСР?")
assert "ПТСР – это расстройство" in joined(r)
# --- free text in idle ------------------------------------------------------------------------------------
async def test_free_text_question_answered_from_guidelines(engine: Engine) -> None:
r = await say(engine, "помогает ли психотерапия при ПТСР?")
text = joined(r)
assert "В клинических рекомендациях Минздрава сказано:" in text
assert "Источник: КР ПТСР 2023" in text
@pytest.mark.parametrize("message", ["сегодня обычный день", "как дела?"])
async def test_free_text_without_answer_keeps_support(engine: Engine, message: str) -> None:
r = await say(engine, message)
assert joined(r) == SUPPORT
assert "Источник" not in joined(r)
async def test_drug_question_stays_with_instruction(engine: Engine) -> None:
# SPEC §7 п.5: the patient's own drug is answered only from its instruction, never from the guidelines.
r = await say(engine, "через сколько подействуют таблетки?")
text = joined(r)
assert text.startswith(load_med_qa_texts().intro)
assert "клинических рекомендациях" not in text
async def test_side_effect_question_goes_to_checkin(engine: Engine) -> None:
r = await say(engine, "меня тошнит от таблеток, это нормально?")
assert "клинических рекомендациях" not in joined(r)
async def test_acute_risk_never_reaches_knowledge_base(engine: Engine) -> None:
r = await say(engine, "зачем жить? хочу умереть")
assert any(x.kind == "crisis" for x in r)
assert "клинических рекомендациях" not in joined(r)
+323
View File
@@ -0,0 +1,323 @@
"""Demo adapters (#14): МИС mock, connect-code identity, ГРЛС excerpts, doctor notifier, registry setup."""
import json
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import pytest
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription as PrescriptionRow
from app.integrations import (
CompositeNotifier,
ConnectCodeIdentity,
GrlsExcerpts,
MisMock,
NotificationFeed,
setup,
)
from app.integrations import notify as notify_module
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
MemoryOutbox,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.grls import normalize_inn
from app.integrations.notify import TelegramDoctorChat, format_message
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import DOSING_SENTENCE, SECTIONS, med_qa
from app.llm.mock import MockTasks
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
T0 = datetime(2026, 10, 1, 9, 0, tzinfo=UTC)
GRLS_DIR = Path(__file__).resolve().parents[1] / "data" / "meds" / "grls"
CORE_DRUGS = ("сертралин", "пароксетин", "эсциталопрам", "флуоксетин")
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
try:
yield
finally:
await session.configure()
# ----------------------------------------------------------------------------------------------- setup
def test_setup_registers_demo_adapters() -> None:
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.identity, ConnectCodeIdentity)
assert isinstance(integrations.drug_info, GrlsExcerpts)
assert isinstance(integrations.notifier, CompositeNotifier)
# The outbox stays the in-memory queue the web console drains (Telegram wraps it at start).
assert isinstance(integrations.outbox, MemoryOutbox)
def test_setup_is_idempotent() -> None:
before = (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier)
setup()
setup()
assert (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) == before
def test_setup_replaces_only_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
fake_notifier = MemoryNotifier()
sink = type("Sink", (MemoryNotifier,), {})()
monkeypatch.setattr(integrations, "prescriptions", EmptyPrescriptions())
monkeypatch.setattr(integrations, "identity", RejectIdentity())
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
monkeypatch.setattr(integrations, "notifier", fake_notifier)
setup(force=True)
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.notifier, CompositeNotifier)
monkeypatch.setattr(integrations, "notifier", sink)
monkeypatch.setattr("app.integrations._done", False)
setup()
assert integrations.notifier is sink # a swapped-in test sink is kept
# --------------------------------------------------------------------------------------------- mis_mock
async def test_mis_mock_catalogue_fallback() -> None:
mis = MisMock(use_db=False)
[rx] = await mis.get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.schedule["times"]) == ("сертралин", "50 мг", ["08:00"])
assert rx.instructions == "утром после еды"
assert await mis.get_prescriptions("unknown") == []
async def test_mis_mock_catalogue_drugs_have_excerpts() -> None:
raw = json.loads((GRLS_DIR.parent / "mis_prescriptions.json").read_text(encoding="utf-8"))
known = set(GrlsExcerpts().known())
assert {rx["drug"] for p in raw["patients"] for rx in p["prescriptions"]} <= known
async def test_mis_mock_missing_catalogue_is_empty(tmp_path: Path) -> None:
assert await MisMock(tmp_path / "nope.json", use_db=False).get_prescriptions("tok-andrey") == []
async def test_mis_mock_prefers_db(dbs: None) -> None:
async with session.get_clinical_session() as s:
s.add(
PrescriptionRow(
token="tok-andrey",
drug="эсциталопрам",
dose="10 мг",
schedule={"times": ["09:00"], "instructions": "утром"},
start_date=date(2026, 10, 1),
)
)
[rx] = await MisMock().get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.instructions, rx.start_date) == ("эсциталопрам", "10 мг", "утром", date(2026, 10, 1))
# No DB rows for this token -> catalogue.
[rx] = await MisMock().get_prescriptions("tok-sergey")
assert rx.drug == "пароксетин"
async def test_mis_mock_inside_caller_session_keeps_its_work(dbs: None) -> None:
# The dialog calls the adapter while its own clinical session holds flushed changes.
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-smirnova")
assert await MisMock().get_prescriptions(TOKEN) == []
async with session.get_clinical_session() as s:
assert await repo.get_profile(s, TOKEN) is not None
async def test_mis_mock_db_down_falls_back(tmp_path: Path) -> None:
await session.configure(clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
[rx] = await MisMock().get_prescriptions("tok-andrey")
finally:
await session.configure()
assert rx.drug == "сертралин"
# ------------------------------------------------------------------------------------------ connect code
async def _code(code: str = "DEMO-0001") -> None:
async with session.get_identity_session() as s:
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-smirnova", now=T0)
async def test_connect_code_verifies_once(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
await _code()
ident = ConnectCodeIdentity()
monkeypatch.setattr(clock, "now", lambda: T0)
person = await ident.verify(" DEMO-0001 ")
assert person is not None and (person.token, person.doctor_id) == (TOKEN, "doc-smirnova")
# Same connect flow (Telegram /start, then the engine's `new` state) re-checks the code: still valid.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(minutes=1))
assert await ident.verify("DEMO-0001") == person
# Later the used code is rejected.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(hours=1))
assert await ident.verify("DEMO-0001") is None
async def test_connect_code_unknown_or_empty(dbs: None) -> None:
assert await ConnectCodeIdentity().verify("NOPE") is None
assert await ConnectCodeIdentity().verify(" ") is None
async def test_connect_code_db_down(tmp_path: Path) -> None:
await session.configure(identity_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
assert await ConnectCodeIdentity().verify("DEMO-0001") is None
finally:
await session.configure()
# ------------------------------------------------------------------------------------------------- ГРЛС
async def test_grls_core_drugs_have_all_sections() -> None:
grls = GrlsExcerpts()
for inn in CORE_DRUGS:
sections = await grls.sections(inn)
assert {s.section for s in sections} == set(SECTIONS), inn
assert all(s.inn == inn and s.source.startswith("ГРЛС, инструкция по медицинскому") for s in sections)
assert {"миртазапин", "празозин"} <= set(grls.known())
async def test_grls_texts_are_short_safe_and_dose_free() -> None:
grls = GrlsExcerpts()
for inn in grls.known():
for s in await grls.sections(inn):
sentences = [x for x in s.text.replace("! ", ". ").split(". ") if x]
assert 1 <= len(sentences) <= 4, (inn, s.section)
assert not is_forbidden(s.text), (inn, s.section)
if s.section != "administration":
assert not DOSING_SENTENCE.search(s.text), (inn, s.section)
assert "мг" not in s.text.split(), (inn, s.section)
def test_grls_files_carry_verification_header() -> None:
for path in GRLS_DIR.glob("*.yaml"):
assert "сверить с действующей инструкцией ГРЛС" in path.read_text(encoding="utf-8").splitlines()[0], path
async def test_grls_lookup_normalisation() -> None:
grls = GrlsExcerpts()
assert normalize_inn("Сертралин 50 мг") == "сертралин"
assert await grls.sections("Sertraline") == await grls.sections("сертралин 50 мг")
assert await grls.sections("аспирин") == []
async def test_grls_bad_files_are_skipped(tmp_path: Path) -> None:
(tmp_path / "bad.yaml").write_text("inn: [", encoding="utf-8")
(tmp_path / "wrong.yaml").write_text("inn: x\n", encoding="utf-8")
assert GrlsExcerpts(tmp_path).known() == []
assert await GrlsExcerpts(tmp_path / "missing").sections("сертралин") == []
# --------------------------------------------------------------- med_qa end-to-end on real sertraline data
@pytest.fixture
def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
async def test_med_qa_day2_nausea(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("подташнивает с утра"))
assert answer is not None and answer.section == "side_effects"
assert "тошнот" in answer.text.lower() and "1–2 недель" in answer.text
assert "Побочное действие" in answer.source
async def test_med_qa_day5_quit(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("от таблеток только хуже, брошу"), topic="quit_intent")
assert answer is not None and answer.section == "onset"
assert "2–4 недели" in answer.text
async def test_med_qa_day10_alcohol(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("без пива не могу уснуть"))
assert answer is not None and answer.section == "alcohol"
assert "алкогол" in answer.text.lower() and "сон" in answer.text
async def test_med_qa_other_drugs_answer_core_topics(mock_gateway: None) -> None:
for inn in CORE_DRUGS:
for question in ("тошнит после таблетки", "когда начнёт действовать", "можно ли выпить пива", "хочу бросить"):
answer = await med_qa(inn, ScrubbedText(question))
assert answer is not None, (inn, question)
assert "мг" not in answer.text
async def test_med_qa_dose_question_still_goes_to_doctor(mock_gateway: None) -> None:
assert await med_qa("сертралин", ScrubbedText("можно увеличить дозу?")) is None
# ------------------------------------------------------------------------------------------------ notify
class FakeChat:
def __init__(self, fail: bool = False) -> None:
self.sent: list[tuple[int, str]] = []
self.fail = fail
async def __call__(self, chat_id: int, text: str) -> None:
if self.fail:
raise RuntimeError("telegram down")
self.sent.append((chat_id, text))
async def test_feed_ids_are_monotonic_and_filterable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clock, "now", lambda: T0)
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=None, bot_token=""))
await notifier.send("doc-smirnova", "moderate", "alcohol_3d", TOKEN)
await notifier.send("doc-other", "high", "sleep_lt4_3n", "b" * 32)
await notifier.send("doc-smirnova", "acute", "phq9_item9", TOKEN)
items = notifier.recent()
assert [n.id for n in items] == [1, 2, 3] and items[0].ts == T0
assert [n.text for n in notifier.recent(since_id=1, doctor_id="doc-smirnova")] == ["phq9_item9"]
assert notifier.recent(since_id=3) == []
def test_feed_is_bounded() -> None:
feed = NotificationFeed(size=2)
for i in range(3):
feed.add("d", "info", f"r{i}", None, T0)
assert [n.id for n in feed.recent()] == [2, 3]
async def test_telegram_doctor_chat_message_has_no_pii() -> None:
chat = FakeChat()
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=-100, bot_token="", sender=chat))
await notifier.send("doc-smirnova", "acute", "PHQ-9 п.9 = 2 — кризисный протокол", TOKEN)
[(chat_id, text)] = chat.sent
assert chat_id == -100
assert text == "[КРИТИЧНО] PHQ-9 п.9 = 2 — кризисный протокол\nПациент #aaaaaa"
assert TOKEN not in text
async def test_telegram_failure_is_swallowed() -> None:
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=1, bot_token="", sender=FakeChat(fail=True)))
await notifier.send("doc-smirnova", "high", "sleep_lt4_3n", TOKEN) # does not raise
assert len(notifier.recent()) == 1
def test_telegram_chat_disabled_without_settings() -> None:
assert not TelegramDoctorChat(chat_id=None, bot_token="x").enabled
assert not TelegramDoctorChat(chat_id=5, bot_token="").enabled
assert TelegramDoctorChat(chat_id=5, bot_token="123:abc").enabled
def test_format_message_trims_long_reason() -> None:
text = format_message("info", "x" * 500, None)
assert len(text.splitlines()[0]) <= notify_module.MAX_REASON_CHARS + len("[Инфо] ")
assert text.endswith("Пациент #—")
+199
View File
@@ -0,0 +1,199 @@
import json
import httpx
import pytest
from app.config import Settings
from app.core.privacy import pseudo_id, scrub
from app.core.types import ScrubbedText
from app.integrations.base import Prescription, integrations
from app.integrations.emias_record import emias_records
from app.llm import agent
from app.llm.agent.drug_safety import check_patient, find_drug
from app.llm.agent.providers import MockAgentProvider, OpenRouterProvider
from app.llm.agent.tools import ToolContext, run_tool
ANDREY = pseudo_id("900-000-013 34")
ANNA = pseudo_id("900-000-023 04")
SERGEY = pseudo_id("900-000-037 02")
class _Rx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="50 мг", instructions="утром после еды")]
@pytest.fixture
def sertraline(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("сертралин"))
# ----------------------------------------------------------------------------------------------- drug safety
@pytest.mark.parametrize("name", ["сертралин", "сертралине", "Золофт", "сертралин 50 мг", "sertraline"])
def test_find_drug_forms(name):
assert find_drug(name).inn == "сертралин"
def test_find_drug_unknown():
assert find_drug("аспирин") is None
async def test_andrey_sertraline_findings(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("сертралин"), rec)
kinds = {(f.kind, f.severity) for f in findings}
assert ("condition", "high") in kinds # ulcer history
assert any(f.kind == "interaction" and "ибупрофен" in f.text for f in findings)
async def test_ibuprofen_sees_current_ssri(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("ибупрофен"), rec)
assert any("СИОЗС" in f.text and "сертралин" in f.text for f in findings)
async def test_pregnancy_and_triptan(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("флуоксетин"))
anna = await emias_records.get(ANNA)
assert any(f.kind == "pregnancy" for f in check_patient(find_drug("флуоксетин"), anna))
monkeypatch.setattr(integrations, "prescriptions", _Rx("пароксетин"))
sergey = await emias_records.get(SERGEY)
assert any("Триптаны" in f.text for f in check_patient(find_drug("пароксетин"), sergey))
# ----------------------------------------------------------------------------------------------------- tools
async def test_record_tool_has_no_identifiers(sertraline):
out = await run_tool(ToolContext(token=ANDREY), "get_patient_record", {})
data = json.loads(out)
assert data["found"] and data["current_prescriptions"][0]["drug"] == "сертралин"
assert "900-000" not in out and "Андрей" not in out
async def test_search_tool_drops_doses():
out = json.loads(await run_tool(ToolContext(token=ANDREY), "search_guidelines", {"query": "дозы СИОЗС при ПТСР"}))
for r in out["results"]:
assert " мг" not in r["text"]
async def test_unknown_tool_is_json_error():
assert "error" in json.loads(await run_tool(ToolContext(token=ANDREY), "rm_rf", {}))
# ------------------------------------------------------------------------------------------------ agent loop
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "chat_agent": "openrouter", "chat_agent_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake_openrouter(final_text: str, calls: list[dict]) -> tuple[OpenRouterProvider, list[dict]]:
sent: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
sent.append(body)
if len(sent) == 1:
msg = {"role": "assistant", "content": None, "tool_calls": calls}
else:
msg = {"role": "assistant", "content": final_text}
return httpx.Response(200, json={"choices": [{"message": msg}], "usage": {"total_tokens": 10}})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return OpenRouterProvider(_settings(), client=client), sent
_CALLS = [
{"id": "a", "type": "function", "function": {"name": "get_patient_record", "arguments": "{}"}},
{
"id": "b",
"type": "function",
"function": {"name": "check_drug_for_patient", "arguments": '{"drug": "ибупрофен"}'},
},
]
async def test_agent_openrouter_tool_loop(sertraline):
text = (
"В вашей карте отмечена язвенная болезнь, а ибупрофен с сертралином повышает риск кровотечений. "
"Обсудите с врачом."
)
provider, sent = _fake_openrouter(text, _CALLS)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен? Мой телефон 89161234567"), provider=provider, settings=_settings()
)
assert ans is not None and ans.text == text
assert ans.tools == ["get_patient_record", "check_drug_for_patient"]
assert any("ЕМИАС" in s for s in ans.sources)
first, second = sent
assert "89161234567" not in json.dumps(first, ensure_ascii=False) # scrubbed before the provider
tool_msgs = [m for m in second["messages"] if m["role"] == "tool"]
assert len(tool_msgs) == 2 and all("name" not in m for m in tool_msgs)
assert first["tools"] and first["model"]
async def test_forbidden_output_rejected(sertraline):
provider, _ = _fake_openrouter("Увеличьте дозу сертралина вдвое.", _CALLS)
assert await agent.answer_chat(ANDREY, scrub("что делать"), provider=provider, settings=_settings()) is None
async def test_provider_error_returns_none():
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500)))
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unscrubbed_input_never_sent():
provider, sent = _fake_openrouter("ok", [])
assert await agent.answer_chat(ANDREY, ScrubbedText("звоните 8 916 123-45-67"), provider=provider) is None
assert sent == []
async def test_mock_agent_answers_offline(sertraline):
ans = await agent.answer_chat(
ANDREY, scrub("Можно ли мне ибупрофен вместе с моими таблетками?"), provider=MockAgentProvider()
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "mock"
def test_build_provider_modes():
assert agent.build_provider(Settings(_env_file=None, chat_agent="auto")) is None
assert isinstance(agent.build_provider(_settings(chat_agent="auto")), OpenRouterProvider)
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None
async def test_check_defaults_to_current_prescription(sertraline):
out = json.loads(await run_tool(ToolContext(token=ANDREY), "check_drug_for_patient", {"drug": "мои таблетки"}))
assert out["found"] and out["inn"] == "сертралин"
assert "лактоз" in out["intolerance_check"] and "нет" in out["intolerance_check"]
assert any("Алкоголь" in t for t in out["general_interactions"])
async def test_empty_final_answer_is_nudged(sertraline):
replies = iter(
[
{"role": "assistant", "content": None, "tool_calls": _CALLS},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": "Алкоголь во время лечения не рекомендуется. Обсудите с врачом."},
]
)
sent = []
def handler(request):
sent.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": next(replies)}]})
provider = OpenRouterProvider(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
ans = await agent.answer_chat(ANDREY, scrub("пиво можно?"), provider=provider, settings=_settings())
assert ans is not None and "Алкоголь" in ans.text
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt
+4 -3
View File
@@ -3,7 +3,7 @@
import pytest import pytest
from app.core.types import ScrubbedText from app.core.types import ScrubbedText
from app.integrations.base import DrugSection from app.integrations.base import DrugSection, NoDrugInfo, integrations
from app.llm import med_qa as med_qa_module from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt
@@ -173,8 +173,9 @@ async def test_unknown_drug_and_unknown_topic(info: FakeDrugInfo, mock_gateway:
assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None
async def test_default_registry_has_no_data(mock_gateway: None) -> None: async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None:
# Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing. # Without excerpts (base.py default NoDrugInfo; #14 registers real ones at import) nothing is answered.
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
assert await med_qa("сертралин", S("тошнит")) is None assert await med_qa("сертралин", S("тошнит")) is None
+35
View File
@@ -307,3 +307,38 @@ async def test_llm_rephrasing_kept_when_faithful(monkeypatch: pytest.MonkeyPatch
reply = await answer(S(q), audience="patient") reply = await answer(S(q), audience="patient")
assert reply is not None and reply.phrased_by_llm assert reply is not None and reply.phrased_by_llm
assert simplified in reply.text and reply.excerpt == excerpt # the verbatim quote is kept for audit assert simplified in reply.text and reply.excerpt == excerpt # the verbatim quote is kept for audit
# --- regressions from the post-merge audit (#62) -------------------------------------------------------
def test_paragraph_flowing_to_next_page_cites_both_pages() -> None:
"""The converter glues a paragraph across a page break and marks the join with ⟦стр. N⟧."""
chunks = load_chunks()
assert not any("⟦" in c.text for c in chunks) # markers never reach the text
# KR PTSD: the comment on antipsychotics starts on p.22 and continues on p.23
chunk = next(c for c in chunks if "Препараты могут применяться в комбинированной терапии" in c.text)
assert chunk.page_start <= 23 <= chunk.page_end
async def test_scrub_placeholders_do_not_break_answers() -> None:
plain = await answer(S("что такое ПТСР"), audience="patient")
scrubbed = await answer(S("[NAME], [PHONE], что такое ПТСР"), audience="patient")
assert plain is not None and scrubbed is not None
assert scrubbed.excerpt == plain.excerpt
assert tokens("[NAME] сон") == ["сон"]
def test_question_naming_a_disease_prefers_its_guideline() -> None:
index = get_index()
assert index.cued_docs("ТМС при депрессии") == {"kr_depression"}
assert index.cued_docs("кошмары после войны") == {"kr_ptsd"}
assert index.cued_docs("депрессия и ПТСР") == set() # both named: no preference
assert index.cued_docs("когда подействуют антидепрессанты") == set() # a drug class, not the disease
hit = index.search("транскраниальная магнитная стимуляция при депрессии", audience="patient")[0]
assert hit.chunk.doc.id == "kr_depression"
async def test_answer_follows_disease_cue() -> None:
reply = await answer(S("транскраниальная магнитная стимуляция при депрессии"), audience="patient")
assert reply is not None and reply.citations[0].doc == "kr_depression"
+333
View File
@@ -0,0 +1,333 @@
"""Demo seed (#18): idempotent cabinet, Andrey's modes, colours of the 7 patients, and the console scenario
`data/synthetic/andrey.jsonl` replayed against the real dialog engine + demo scheduler + monitoring (sqlite)."""
import re
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from typing import Any
import httpx
import pytest
from sqlalchemy import func, select
from app.config import Settings
from app.core import clock
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.clinical import ClinicalBase, Dose, SafetyPlan
from app.db.identity import ConnectCode, IdentityBase
from app.dialog import contracts as dialog_contracts
from app.dialog.contracts import Button, Event, Reply
from app.dialog.engine import Engine
from app.integrations.base import MemoryOutbox, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.scheduler import jobs
from app.services import monitoring as _monitoring # noqa: F401 (registers the real MonitoringService)
from scripts import seed_demo
from scripts.seed_demo import ANDREY_COLORS, ANDREY_DAYS, OTHERS, SeedResult, seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0) # virtual "now" of the seed (Europe/Moscow)
@pytest.fixture
async def dbs(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
yield
clock.reset()
await session.configure()
async def counts() -> dict[str, int]:
out: dict[str, int] = {}
for base, factory in ((ClinicalBase, session.get_clinical_session), (IdentityBase, session.get_identity_session)):
async with factory() as s:
for table in base.metadata.sorted_tables:
out[table.name] = await s.scalar(select(func.count()).select_from(table)) or 0
return out
async def open_codes(token: str, *, acked: bool = False) -> set[str]:
async with session.get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token)
return {a.code for a in alerts if (a.acked_at is not None) == acked}
async def color(token: str) -> str | None:
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
return profile.status_color if profile else None
# ----------------------------------------------------------------------------------------------- seed
async def test_seed_is_idempotent(dbs: None) -> None:
await seed(12)
first = await counts()
await seed(12)
assert await counts() == first
assert first["patient_profiles"] == first["patients"] == 8 and first["doctors"] == 1
await seed(0)
again = await counts()
await seed(0)
assert await counts() == again
async def test_reset_recreates_tables_and_zeroes_clock(dbs: None) -> None:
await seed(5)
clock.advance(days=2)
assert await seed_demo.reset(None) is None
assert clock.offset() == timedelta(0)
assert all(v == 0 for k, v in (await counts()).items() if k != "clock_state")
result = await seed_demo.reset() # what POST /demo/reset calls: reset + Andrey on day 0
assert result is not None and result.colors[seed_demo.ANDREY_NAME] == "green"
assert (await counts())["patient_profiles"] == 8
async def test_andrey_day0_is_ready_for_the_live_demo(dbs: None) -> None:
result = await seed(0)
token = result.andrey_token
assert re.fullmatch(r"[A-HJ-NP-Z2-9]{6}", result.connect_code)
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, token)
doctor = await identity_repo.get_doctor(s, seed_demo.DOCTOR_ID)
code = await s.get(ConnectCode, result.connect_code)
assert patient is not None and patient.consent_at is None and patient.display_name == "Андрей С."
assert doctor is not None and doctor.on_call and doctor.full_name == "Смирнова Е. А."
assert code is not None and code.token == token and code.used_at is None
assert await identity_repo.redeem_connect_code(s, code=result.connect_code, now=clock.now()) == token
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None
assert profile.modules == ["veteran", "depression"] and profile.sex == "m"
assert NOW.year - (profile.birth_year or 0) == 34
assert profile.therapy_start == NOW.date() and profile.next_visit == NOW.date() + timedelta(days=28)
assert await repo.get_dialog_state(s, token) is None # consent and onboarding happen live
assert not await repo.list_checkins(s, token) and not await repo.list_scale_results(s, token)
plan = await s.get(SafetyPlan, token)
assert plan is not None and len(plan.steps) == 6
assert result.colors["Андрей С."] == "green"
@pytest.mark.parametrize("day", [3, 4, 5, 6, 9, 10])
async def test_andrey_colour_follows_the_persona(dbs: None, day: int) -> None:
result = await seed(day)
assert result.colors["Андрей С."] == ANDREY_COLORS[day - 1]
async def test_andrey_day12_yellow(dbs: None) -> None:
result = await seed(12)
token = result.andrey_token
assert result.colors["Андрей С."] == "yellow"
assert await open_codes(token) == {"sleep_lt4_3n", "alcohol_3d"}
assert await open_codes(token, acked=True) == {"side_effect_3d", "quit_intent"} # doctor called on day 6
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start == NOW.date() - timedelta(days=12)
rows = await repo.list_checkins(s, token)
assert [(r.mood, r.sleep_bucket, r.alcohol_bucket) for r in rows] == [
(ANDREY_DAYS[n].mood, ANDREY_DAYS[n].sleep, ANDREY_DAYS[n].alcohol) for n in range(1, 13)
]
phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")]
assert phq == [(14, 0, True), (13, 0, True)]
totals = {r.scale: r.total for r in await repo.list_scale_results(s, token) if r.ts.date() < rows[0].day}
assert totals == {"pcptsd5": 4, "pcl5": 46, "phq9": 14, "auditc": 3}
doses = (await s.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
assert [d.status for d in doses] == ["skipped" if n == 5 else "taken" for n in range(1, 13)]
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "idle" and state.data["consent"] is True
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, token)
assert patient is not None and patient.consent_at is not None
async def test_andrey_day13_red_crisis(dbs: None) -> None:
result = await seed(13)
token = result.andrey_token
assert result.colors["Андрей С."] == "red"
async with session.get_clinical_session() as s:
acute = [a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"]
assert acute == ["phq9_item9"]
last = (await repo.list_scale_results(s, token, "phq9"))[-1]
assert (last.total, last.item9, last.scheduled) == (18, 2, False)
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "crisis" and state.data["crisis"]["open"] is True
audits = (await s.scalars(select(AuditLog.action).where(AuditLog.token == token))).all()
assert "crisis_acute" in audits and "ack_alert" in audits
async def test_other_patients_match_the_persona_table(dbs: None) -> None:
result = await seed(0)
expected_codes = {
"Дмитрий В.": {"sleep_lt4_3n"},
"Сергей П.": {"missed_doses_7d"},
"Николай Ф.": {"alcohol_3d"},
"Анна Л.": {"side_effect_3d"},
"Ирина К.": set(),
"Ольга Н.": set(),
"Павел Р.": set(),
}
for spec in OTHERS:
token = result.tokens[spec.name]
assert result.colors[spec.name] == spec.color, spec.name
assert await open_codes(token) == expected_codes[spec.name], spec.name
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start is not None
assert (NOW.date() - profile.therapy_start).days == spec.therapy_day
assert NOW.year - (profile.birth_year or 0) == spec.age
assert profile.next_visit is not None and profile.next_visit > NOW.date()
assert await repo.get_dialog_state(s, token) is None # the scheduler leaves them alone
assert await open_codes(result.tokens["Ольга Н."], acked=True) == {"side_effect_3d"} # «тошнота прошла»
async with session.get_clinical_session() as s:
anna = await repo.get_profile(s, result.tokens["Анна Л."])
assert anna is not None and anna.enhanced_monitoring # age < 25, first weeks of therapy
pavel = await repo.list_scale_results(s, result.tokens["Павел Р."], "phq9")
irina = await repo.list_scale_results(s, result.tokens["Ирина К."], "phq9")
assert pavel[-1].total == 4 and [r.total for r in irina] == [16, 13, 9]
async def test_seed_is_deterministic_except_connect_code(dbs: None) -> None:
async def snapshot(result: SeedResult) -> list[Any]:
async with session.get_clinical_session() as s:
out = []
for token in result.tokens.values():
out += [(c.day, c.mood, c.sleep_bucket, c.alcohol_bucket) for c in await repo.list_checkins(s, token)]
out += [(a.code, a.ts) for a in await repo.list_alerts(s, token=token)]
return out
first = await snapshot(await seed(12))
assert await snapshot(await seed(12)) == first
# ----------------------------------------------------------------------------------------------- scenario file
def test_scenario_file_format_and_in_sync_with_config() -> None:
lines = seed_demo.load_scenario()
assert lines == seed_demo.scenario_lines(), "regenerate: python -m scripts.seed_demo --write-scenario"
assert [line["day"] for line in lines] == list(range(0, 14))
for line in lines:
assert set(line) == {"day", "note", "actions"} and line["note"]
for action in line["actions"]:
assert len(action) == 1 and set(action) <= seed_demo.ALLOWED_ACTIONS, action
((key, value),) = action.items()
assert isinstance(value, str) and value
if key == "advance_to":
assert re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value)
if key == "expect_color":
assert value in {"green", "yellow", "red"}
assert lines[13]["actions"][-1] == {"expect_color": "red"}
class Console:
"""What the demo console (#17) does with a scenario line, in-process: the demo clock + scheduler instead of
`/demo/clock/advance`, the engine instead of `/dialog/{token}/events`, the outbox instead of its poll."""
def __init__(self, token: str, engine: Engine, outbox: MemoryOutbox) -> None:
self.token, self.engine, self.outbox = token, engine, outbox
self.batch: list[Reply] = []
async def advance_to(self, hhmm: str) -> None:
now = clock.now()
hh, mm = map(int, hhmm.split(":"))
target = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
clock.set_now(target)
await jobs.run_until_now()
self.batch = self.outbox.drain(self.token)
def resolve(self, label: str) -> Button:
buttons = [b for r in self.batch for row in r.buttons for b in row]
hits = [b for b in buttons if label in b.label]
where = f"{clock.now():%d %H:%M}: {[r.text[:50] for r in self.batch]}"
assert hits, f"no button «{label}» in {[b.label for b in buttons]} at {where}"
# Unambiguous for any console strategy (first substring hit or exact match).
assert hits[0].label == label and len({b.data for b in buttons if b.label == label}) == 1, label
return hits[0]
async def press(self, label: str) -> None:
button = self.resolve(label)
self.batch = await self.engine.handle(self.token, Event(type="button", button=button.data))
async def text(self, text: str) -> None:
self.batch = await self.engine.handle(self.token, Event(type="text", text=text))
async def ack_all(token: str) -> None:
"""The presenter acknowledges Andrey's alerts on the dashboard (day 6 note)."""
async with session.get_clinical_session() as s:
for a in await repo.list_alerts(s, token=token, open_only=True):
await repo.ack_alert(s, alert_id=a.id, doctor_id=seed_demo.DOCTOR_ID, ts=clock.now())
await _monitoring.refresh_status(s, token)
async def test_scenario_replays_through_the_real_dialog(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
gateway = LLMGateway(MockTasks())
engine = Engine(gateway=gateway)
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: gateway)
previous = dialog_contracts._factory
dialog_contracts.register_engine(lambda: engine) # the scheduler delivers through the same engine
try:
result = await seed(0)
token = result.andrey_token
console = Console(token, engine, outbox)
colors: dict[int, str | None] = {}
for line in seed_demo.load_scenario():
day = line["day"]
for action in line["actions"]:
((key, value),) = action.items()
if day == 6 and action == {"advance_to": "20:00"}:
await ack_all(token) # «до вечернего чек-ина подтвердите алерты»
if key == "expect_color":
assert await color(token) == value, f"day {day}"
else:
await getattr(console, key)(value)
colors[day] = await color(token)
assert (clock.today() - NOW.date()).days == day
finally:
dialog_contracts.register_engine(previous)
assert [colors[d] for d in range(1, 14)] == ANDREY_COLORS
assert any(r.kind == "crisis" for r in console.batch) and any(r.kind == "safety_plan" for r in console.batch)
async with session.get_clinical_session() as s:
rows = {(c.day - NOW.date()).days: c for c in await repo.list_checkins(s, token)}
for n, d in ANDREY_DAYS.items():
c = rows[n]
assert (c.mood, c.med_taken, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
d.mood,
d.med_taken,
d.sleep,
d.nightmares,
d.alcohol,
), n
phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")]
assert phq == [(14, 0, True), (13, 0, True), (18, 2, False)]
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "crisis"
# The 7 background patients are not touched by 13 days of the demo clock.
for spec in OTHERS:
assert await color(result.tokens[spec.name]) == spec.color, spec.name
async def test_api_serves_the_seeded_cabinet(dbs: None) -> None:
from app.main import app
result = await seed(13)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
rows = (await c.get(f"/api/v1/doctors/{seed_demo.DOCTOR_ID}/patients")).json()
personas = (await c.get("/api/v1/demo/personas")).json()
card = await c.get(f"/api/v1/patients/{result.andrey_token}", params={"doctor_id": seed_demo.DOCTOR_ID})
assert len(rows) == 8 and rows[0]["display_name"] == "Андрей С." and rows[0]["status_color"] == "red"
assert {r["display_name"]: r["status_color"] for r in rows} == result.colors
assert personas[0]["token"] == result.andrey_token and personas[0]["therapy_day"] == 13
assert personas[0]["scenario"] == "data/synthetic/andrey.jsonl"
assert card.status_code == 200 and len(card.json()["safety_plan"]) == 6
+1 -1
View File
@@ -44,7 +44,7 @@ export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }), signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }), signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }), signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
demoPersonas: () => request<DemoPersona[]>("/demo/personas"), demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"), crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"), catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"), medsReference: () => request<MedsReference>("/meds/reference"),