release: dev → main (сдача хакатона) #106
@@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS=
|
||||
GIGACHAT_SCOPE=GIGACHAT_API_PERS
|
||||
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))"
|
||||
PSEUDO_KEY=
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
+13
-6
@@ -27,6 +27,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import DemoPersona
|
||||
from app.core import clock
|
||||
from app.core.instrument_catalog import Catalog, catalog
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
@@ -133,9 +134,15 @@ async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||||
return AuthOut(token=person.token, mode="emias")
|
||||
|
||||
|
||||
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
|
||||
async def patient_demo_personas() -> list[DemoPersona]:
|
||||
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
|
||||
return fixtures.DEMO_PERSONAS
|
||||
|
||||
|
||||
@router.post("/auth/emias/demo", response_model=AuthOut)
|
||||
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/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)
|
||||
if persona is None:
|
||||
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:
|
||||
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
|
||||
|
||||
With `focus` a doctor-less pseudonymous profile keeps only the chosen module, so the engine starts the
|
||||
matching onboarding instead of going straight to the menu. Without it nothing is stored at all.
|
||||
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
|
||||
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
|
||||
"""
|
||||
token = secrets.token_hex(TOKEN_LEN // 2)
|
||||
if body is not None and body.focus is not None:
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[body.focus]])
|
||||
focus = body.focus if body is not None else None
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else [])
|
||||
log.info("web auth: anonymous tok=%s", token[:6])
|
||||
return AuthOut(token=token, mode="anonymous")
|
||||
|
||||
|
||||
+193
-24
@@ -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.
|
||||
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract).
|
||||
Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
|
||||
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.patient_web import router as patient_web_router
|
||||
from app.api.questionnaire import router as questionnaire_router
|
||||
@@ -24,70 +33,204 @@ from app.api.schemas import (
|
||||
PatientListItem,
|
||||
)
|
||||
from app.core import clock
|
||||
from app.dialog.contracts import get_engine
|
||||
from app.integrations.base import MemoryOutbox, integrations
|
||||
from app.core.privacy import pseudo_id
|
||||
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.services.monitoring import refresh_status
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(questionnaire_router)
|
||||
router.include_router(patient_web_router)
|
||||
|
||||
DEFAULT_CLINIC = "clinic-demo"
|
||||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
|
||||
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
|
||||
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
|
||||
async def list_patients(doctor_id: str) -> list[PatientListItem]:
|
||||
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
|
||||
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
|
||||
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score))
|
||||
now = clock.now()
|
||||
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"])
|
||||
async def patient_card(token: str, doctor_id: str) -> PatientCard:
|
||||
"""Patient card. Every call is written to audit_log as `view_patient` (#12)."""
|
||||
card = fixtures.card(token)
|
||||
if card is None:
|
||||
raise HTTPException(404, "patient not found")
|
||||
"""Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
|
||||
now = clock.now()
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
@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]:
|
||||
"""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]
|
||||
return [a for a in alerts if a.acked_at is None] if open_only else alerts
|
||||
async with get_clinical_session() as s:
|
||||
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"])
|
||||
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."""
|
||||
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None)
|
||||
if alert is None:
|
||||
raise HTTPException(404, "alert not found")
|
||||
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
|
||||
now = clock.now()
|
||||
async with get_clinical_session() as s:
|
||||
alert = await s.get(Alert, alert_id)
|
||||
if alert is None:
|
||||
raise HTTPException(404, "alert not found")
|
||||
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"])
|
||||
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."""
|
||||
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)
|
||||
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"])
|
||||
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
|
||||
await _require_patient(token)
|
||||
return DialogOut(replies=await get_engine().handle(token, body))
|
||||
|
||||
|
||||
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
|
||||
async def dialog_outbox(token: str) -> DialogOut:
|
||||
"""System-initiated replies (reminders, check-ins) waiting for the web console."""
|
||||
box = integrations.outbox
|
||||
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else [])
|
||||
box = console.memory_outbox()
|
||||
return DialogOut(replies=box.drain(token) if box is not None else [])
|
||||
|
||||
|
||||
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
|
||||
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)
|
||||
@@ -110,10 +253,36 @@ async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
|
||||
|
||||
@router.post("/demo/reset", tags=["demo"])
|
||||
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()
|
||||
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"])
|
||||
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
|
||||
|
||||
@@ -28,6 +28,16 @@ class Settings(BaseSettings):
|
||||
gigachat_model: str = "GigaChat"
|
||||
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.
|
||||
tg_bot_token: str = ""
|
||||
doctor_notify_chat_id: int | None = None
|
||||
|
||||
@@ -39,6 +39,7 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
||||
buttons = [
|
||||
[Button(label=b.checkin, data="menu:checkin")],
|
||||
[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.revoke, data="consent:revoke")],
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -42,7 +42,8 @@ from app.dialog.contracts import Button, Event, 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.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"
|
||||
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 → «уточните у врача»."""
|
||||
if rx is None or ctx.text is None:
|
||||
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)
|
||||
return answer.text if answer is not None else fallback_text()
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from app.db import repo
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.flows import intake
|
||||
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 (
|
||||
answer_question,
|
||||
checkin_of,
|
||||
@@ -423,11 +424,13 @@ async def _visit(ctx: Ctx, event: Event) -> Step:
|
||||
@trigger("text:idle", replace=True)
|
||||
async def _idle_text(ctx: Ctx, event: Event) -> Step:
|
||||
"""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
|
||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||
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] = {}
|
||||
if facts.quit_intent:
|
||||
fields["quit_intent"] = True
|
||||
@@ -445,6 +448,8 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
|
||||
await save_checkin(ctx, **fields)
|
||||
if text is None and facts.med_question and not facts.quit_intent:
|
||||
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:
|
||||
text = await ctx.gateway.phrase("support")
|
||||
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
|
||||
|
||||
@@ -47,6 +47,7 @@ class AgeGateTexts(_Strict):
|
||||
class IdleButtons(_Strict):
|
||||
checkin: str
|
||||
med_question: str
|
||||
kb_question: str
|
||||
contact_doctor: str
|
||||
revoke: str
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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), [])]
|
||||
@@ -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, [])]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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, и что врач будет уведомлён."""
|
||||
@@ -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)
|
||||
@@ -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
@@ -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);
|
||||
* 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;
|
||||
* 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
|
||||
@@ -25,6 +26,8 @@ from app.config import get_settings
|
||||
Audience = str # "doctor" | "patient" | "team"
|
||||
|
||||
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"^\|(-+\|)+$")
|
||||
# "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 ..."
|
||||
@@ -77,6 +80,9 @@ class RetrievalConfig(BaseModel):
|
||||
window_weight: float = 0.5
|
||||
# section title prefix -> score multiplier, per audience ({"patient": {"Приложение В": 1.5}})
|
||||
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):
|
||||
@@ -120,8 +126,46 @@ class _Unit:
|
||||
"""Indivisible piece of a chunk: a paragraph (with its comment) or one table row."""
|
||||
|
||||
text: str
|
||||
page: int
|
||||
page: int # page where the unit starts
|
||||
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
|
||||
@@ -185,7 +229,7 @@ def _sections(text: str, skip: list[str]) -> list[_Section]:
|
||||
continue
|
||||
if first.startswith("[Изображение"):
|
||||
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
|
||||
for j, line in enumerate(block):
|
||||
title = heading(line)
|
||||
@@ -199,11 +243,11 @@ def _sections(text: str, skip: list[str]) -> list[_Section]:
|
||||
continue
|
||||
units = sections[-1].units
|
||||
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:
|
||||
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:
|
||||
units.append(_Unit(line, page))
|
||||
units.append(_new_unit(line, page))
|
||||
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
|
||||
else:
|
||||
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:
|
||||
@@ -280,7 +333,7 @@ def chunk_document(doc: DocumentMeta, text: str, cfg: ChunkingConfig) -> list[Ch
|
||||
doc=doc,
|
||||
section=section.title,
|
||||
page_start=group[0].page,
|
||||
page_end=group[-1].page,
|
||||
page_end=max(u.page_end for u in group),
|
||||
text=_render(group),
|
||||
)
|
||||
)
|
||||
|
||||
+22
-5
@@ -20,6 +20,8 @@ from app.llm.rag.corpus import Audience, Chunk, RagConfig, load_chunks, load_con
|
||||
from app.llm.rag.stemmer import stem
|
||||
|
||||
_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.
|
||||
STOP_WORDS = frozenset(
|
||||
@@ -36,7 +38,7 @@ STOP_WORDS = frozenset(
|
||||
def tokens(text: str) -> list[str]:
|
||||
"""Stems of content words (hyphenated words are kept whole and also split: "когнитивно-поведенческая")."""
|
||||
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())]
|
||||
if "-" in word and len(parts) > 1:
|
||||
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._candidates, self._window, self._window_weight = r.rerank_candidates, r.window_sentences, r.window_weight
|
||||
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._sentences = [[(s, set(tokens(s))) for s in sentences(c.text)] for c in chunks]
|
||||
self._expander = SynonymExpander(cfg.synonyms)
|
||||
@@ -124,10 +128,20 @@ class BM25Index:
|
||||
score += self._idf[term] * f * (self._k1 + 1) / (f + norm) * weight
|
||||
return score
|
||||
|
||||
def boost(self, chunk: Chunk, audience: Audience | None) -> float:
|
||||
"""Section multiplier for the audience (`retrieval.section_boost`), 1.0 by default."""
|
||||
def boost(self, chunk: Chunk, audience: Audience | None, cued: set[str] | None = None) -> float:
|
||||
"""Section multiplier for the audience (`retrieval.section_boost`) × disease-cue multiplier."""
|
||||
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:
|
||||
"""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
|
||||
]
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
cued = self.cued_docs(query)
|
||||
hits = []
|
||||
for bm25, i in scored[: max(self._candidates, top_k or 0)]:
|
||||
bonus, passage = self._passage(i, terms)
|
||||
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
|
||||
return hits[: top_k or self._top_k]
|
||||
|
||||
|
||||
+6
-3
@@ -56,6 +56,7 @@ PRESCRIBING = re.compile(
|
||||
)
|
||||
# "что такое флэшбек" -> the term; its definition "Флэшбек (flashback – англ.) – ..." is preferred.
|
||||
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_CHARS = 60
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@@ -198,6 +199,7 @@ def best_passage(
|
||||
audience: Audience,
|
||||
cfg: AnswerConfig,
|
||||
defined: set[str] | None = None,
|
||||
cued: set[str] | None = None,
|
||||
) -> Passage | None:
|
||||
"""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")]
|
||||
@@ -235,7 +237,7 @@ def best_passage(
|
||||
size += length(nxt)
|
||||
text = " ".join(s for s in sents[lo : hi + 1] if s)
|
||||
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]:
|
||||
@@ -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)
|
||||
terms = index.query_terms(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:
|
||||
return []
|
||||
# A definition answers "что такое X?" better than any passage that merely mentions X often.
|
||||
|
||||
@@ -73,7 +73,7 @@ F32/F33 со здоровьем:
|
||||
Депрессивный эпизод (ДЭ) - аффективное расстройство, характеризующееся в типичных случаях снижением настроения, утратой интересов и удовольствия, снижением энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся сниженная способность к сосредоточению и вниманию; сниженные самооценка и чувство уверенности в себе; идеи виновности и уничижения (даже при легком типе эпизода); мрачное и пессимистическое видение будущего; идеи или действия, направленные на самоповреждение или суицид; нарушенный сон; сниженный аппетит [1].
|
||||
Рекуррентное депрессивное расстройство (РДР) - аффективное расстройство, характеризующееся повторными эпизодами депрессий, как указано в F32, без анамнестических данных об отдельных эпизодах приподнятого настроения, гиперактивности, которые могли отвечать критериям гипоманиакального, маниакального или смешанного аффективного эпизода (F30.-) [1].
|
||||
1.2 Этиология и патогенез заболевания или состояния (группы заболеваний или состояний)
|
||||
Этиология и патогенез ДЭ и РДР до настоящего времени окончательно не установлены. По всей видимости, возникновение депрессивных расстройств детерминировано сочетанием множества факторов: биологических (конституционально-генетических), психологических и социальных. Среди биологических концепций традиционной является моноаминовая теория депрессии, согласно которой основным патогенетическим механизмом ее возникновения является недостаточность синаптической активности моноаминов (серотонина, дофамина, норадреналина) головного мозга. Как значимые рассматриваются и другие патофизиологические процессы: проявления психосоциального и биологического дистресса, хронобиологическая гипотеза, нарушение процессов нейропластичности, дисрегуляция гипоталамо-гипофизарно-надпочечниковой оси, синтеза нейротрофинов, транскрипционного фактора CREB, а также нейроанатомические особенности гиппокампа, миндалевидного тела (амигдалы), прилежащего ядра и других областей мозга [2, 3, 4].
|
||||
Этиология и патогенез ДЭ и РДР до настоящего времени окончательно не установлены. По всей видимости, возникновение депрессивных расстройств детерминировано сочетанием множества факторов: биологических (конституционально-генетических), психологических и социальных. Среди биологических концепций традиционной является моноаминовая теория депрессии, согласно которой основным патогенетическим механизмом ее возникновения является недостаточность синаптической активности моноаминов (серотонина, дофамина, норадреналина) головного мозга. Как значимые рассматриваются и другие патофизиологические процессы: проявления психосоциального и биологического дистресса, хронобиологическая гипотеза, нарушение процессов нейропластичности, дисрегуляция гипоталамо-гипофизарно-надпочечниковой оси, синтеза нейротрофинов, транскрипционного фактора CREB, а также нейроанатомические особенности гиппокампа, ⟦стр. 3⟧ миндалевидного тела (амигдалы), прилежащего ядра и других областей мозга [2, 3, 4].
|
||||
|
||||
--- стр. 3 ---
|
||||
|
||||
@@ -125,7 +125,7 @@ F33.9 - Рекуррентное депрессивное расстройств
|
||||
В типичных случаях больной с ДЭ страдает от сниженного настроения, утраты интересов и удовольствия, снижения энергичности, которое может привести к повышенной утомляемости и сниженной активности. Отмечается выраженная утомляемость даже при незначительном усилии. К другим симптомам относятся: сниженная способность к сосредоточению и вниманию, сниженные самооценка и чувство уверенности в себе, идеи виновности и уничижения (даже при легком типе эпизода), мрачное и пессимистическое видение будущего, идеи или действия, направленные на самоповреждение или суицид, нарушенный сон, сниженный аппетит.
|
||||
Сниженное настроение мало колеблется в течение дней, и часто нет реакции на окружающие обстоятельства, но могут быть характерные суточные колебания. В некоторых случаях тревога, отчаяние и двигательная ажитация временами могут быть более выраженными, чем депрессия, а изменения настроения также могут быть маскированы дополнительными симптомами: раздражительностью, чрезмерным употреблением алкоголя, истерическим поведением, обострением предшествующих фобических или навязчивых симптомов, ипохондрическими идеями.
|
||||
Некоторые из вышеуказанных симптомов могут быть выраженными и обнаруживают характерные признаки, которые считаются как имеющие специальное клиническое значение. Наиболее типичными примерами являются соматические и психотические симптомы.
|
||||
В некоторых случаях в структуре депрессивного эпизода наблюдаются субсиндромальные маниакальные симптомы, независимо от того, возникают ли они в рамках БАР или РДР. МКБ-10 не разделяет смешанные состояния по доминирующему аффекту. В отличие от МКБ-10, в DSM-5 вводится уточняющий спецификатор "смешанные черты" и выделяются депрессивный эпизод со смешанными чертами и маниакальный эпизод со смешанными чертами.
|
||||
В некоторых случаях в структуре депрессивного эпизода наблюдаются субсиндромальные маниакальные симптомы, независимо от того, возникают ли они в рамках БАР или РДР. МКБ-10 не разделяет смешанные состояния по доминирующему аффекту. В отличие от МКБ-10, в DSM-5 вводится ⟦стр. 5⟧ уточняющий спецификатор "смешанные черты" и выделяются депрессивный эпизод со смешанными чертами и маниакальный эпизод со смешанными чертами.
|
||||
|
||||
--- стр. 5 ---
|
||||
|
||||
@@ -247,7 +247,7 @@ A. критерии F33 удовлетворяются для прошлых э
|
||||
F33.8 Другие рекуррентные депрессивные расстройства
|
||||
F33.9 Рекуррентное депрессивное расстройство неуточненное
|
||||
2.1 Жалобы и анамнез
|
||||
● Всем пациентам с целью первичной диагностики рекомендуется прием (осмотр, консультация) врача-психиатра первичный с психопатологическим обследованием и оценкой риска суицидального поведения [16, 17, 18, 19].
|
||||
● Всем пациентам с целью первичной диагностики рекомендуется прием (осмотр, ⟦стр. 8⟧ консультация) врача-психиатра первичный с психопатологическим обследованием и оценкой риска суицидального поведения [16, 17, 18, 19].
|
||||
|
||||
--- стр. 8 ---
|
||||
|
||||
@@ -273,7 +273,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
- о наличии травм, инфекционных заболеваний головного мозга, соматических заболеваний, предшествовавших развитию депрессивных симптомов [36];
|
||||
- об употреблении психоактивных веществ или лекарственных препаратов, которые могут вызвать симптомы депрессии, непосредственно перед началом депрессивного эпизода [37, 38, 39, 40].
|
||||
Уровень убедительности рекомендаций С (уровень достоверности доказательств - 5).
|
||||
● Всем пациентам с диагностированным первичным или повторным депрессивным эпизодом, получающим терапию, рекомендуется прием (осмотр, консультация) врача-психиатра повторный с проведением оценки эффективности лечения [16, 17].
|
||||
● Всем пациентам с диагностированным первичным или повторным ⟦стр. 9⟧ депрессивным эпизодом, получающим терапию, рекомендуется прием (осмотр, консультация) врача-психиатра повторный с проведением оценки эффективности лечения [16, 17].
|
||||
|
||||
--- стр. 9 ---
|
||||
|
||||
@@ -296,7 +296,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
(Human immunodeficiency virus HIV 2) в крови в рамках первичного обследования, при поступлении в стационар и далее каждые 12 месяцев для исключения ассоциации с
|
||||
ВИЧ-инфекцией, гепатитом, сифилисом [52, 53, 54].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4).
|
||||
● Пациентам с впервые диагностированным ДЭ или РДР либо при наличии клинических или анамнестических показаний с целью исключения патологии щитовидной железы рекомендуется проведение исследования уровня тиреотропного гормона (ТТГ) в крови, уровня свободного трийодтиронина (СТ3) в крови, уровня свободного тироксина (СТ4) сыворотки крови для оценки функции щитовидной железы [
|
||||
● Пациентам с впервые диагностированным ДЭ или РДР либо при наличии ⟦стр. 10⟧ клинических или анамнестических показаний с целью исключения патологии щитовидной железы рекомендуется проведение исследования уровня тиреотропного гормона (ТТГ) в крови, уровня свободного трийодтиронина (СТ3) в крови, уровня свободного тироксина (СТ4) сыворотки крови для оценки функции щитовидной железы [
|
||||
|
||||
--- стр. 10 ---
|
||||
|
||||
@@ -324,7 +324,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
(см. приложение Г4).
|
||||
● Пациентам ДЭ или РДР с сопутствующими расстройствами личности и/или дифференциально-диагностическими трудностями рекомендуется прием (тестирование, консультация) медицинского психолога первичный и проведение патопсихологического-экспериментального-психологического (психодиагностического) исследования [72].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств - 5).
|
||||
● Пациентам с ДЭ или РДР перед назначением терапии с целью проведения дифференциальной диагностики с другими заболеваниями, исключения противопоказаний к ряду препаратов, которые назначаются при ДЭ, при необходимости госпитализации и для оценки различных рисков соматическому здоровью рекомендуется прием (осмотр, консультация) врача-терапевта, прием (осмотр, консультация) врача-невролога, прием (осмотр, консультация) врача-офтальмолога, для женщин - прием (осмотр, консультация) врача-акушера-гинеколога; для женщин фертильного возраста, не использующих надежные методы контрацепции, рекомендуется проведение теста на беременность; для мужчин, предъявляющих жалобы на расстройства сексуальной сферы, рекомендуется прием (осмотр, консультация) врача-уролога [17, 36, 42, 43, 44, 73, 74].
|
||||
● Пациентам с ДЭ или РДР перед назначением терапии с целью проведения дифференциальной диагностики с другими заболеваниями, исключения противопоказаний к ряду препаратов, которые назначаются при ДЭ, при необходимости госпитализации и для оценки различных рисков соматическому здоровью ⟦стр. 11⟧ рекомендуется прием (осмотр, консультация) врача-терапевта, прием (осмотр, консультация) врача-невролога, прием (осмотр, консультация) врача-офтальмолога, для женщин - прием (осмотр, консультация) врача-акушера-гинеколога; для женщин фертильного возраста, не использующих надежные методы контрацепции, рекомендуется проведение теста на беременность; для мужчин, предъявляющих жалобы на расстройства сексуальной сферы, рекомендуется прием (осмотр, консультация) врача-уролога [17, 36, 42, 43, 44, 73, 74].
|
||||
|
||||
--- стр. 11 ---
|
||||
|
||||
@@ -360,7 +360,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
Не существует доказательных данных о большей эффективности или более быстром начале действия АД какого-либо одного класса, хотя при тяжелых, требующих госпитализации, депрессиях некоторые ТЦА (амитриптилин** и кломипрамин**) и венлафаксин оказываются несколько эффективнее, чем селективные ингибиторы обратного захвата серотонина (СИОЗС) [82, 83]. В отношении АД второго поколения сравнительный метаанализ 117 РКИ, включавший 25928 больных, показал, что миртазапин, эсциталопрам, венлафаксин и сертралин** были несколько более эффективны, чем дулоксетин, флуоксетин**, флувоксамин и пароксетин** [84]. В более позднем метаанализе тех же авторов [85] на основе 522 РКИ с участием 116 477 человек и применением 21 антидепрессанта было показано, что показатели отношения шансов были достаточно близкими для различных антидепрессантов как при оценке эффективности, так и переносимости, с широкими доверительными интервалами в большинстве сравнительных анализов. При непосредственном сравнении агомелатин**, амитриптилин**, эсциталопрам, миртазапин, пароксетин**, венлафаксин, и вортиоксетин показали более высокую эффективность, чем другие антидепрессанты, тогда как флуоксетин**, флувоксамин и тразодон оказались менее эффективными препаратами. Что касается переносимости, агомелатин**, циталопрам, эсциталопрам, флуоксетин**, сертралин** и вортиоксетин переносились лучше, чем другие антидепрессанты.
|
||||
АД прежде всего различаются по профилю побочных эффектов, потенциалу взаимодействия с другими препаратами и опасности передозировки (Приложение А3, табл. 3) [86]. АД из классов
|
||||
СИОЗС, СИОЗСН, а также другие АД второго и третьего поколений в целом переносятся лучше, чем препараты первого поколения (ТЦА), что способствует уменьшению вероятности самостоятельного отказа пациентов от их приема. Для ТЦА наиболее характерны антихолинергические (сухость во рту, запоры, нарушение зрения, задержка мочи, тахикардия), сердечно-сосудистые ( α-адренергическая блокада, ортостатическая гипотензия, брадиаритмия, тахикардия), антигистаминные (седация) побочные эффекты, прибавка веса и неврологические симптомы (легкий миоклонус, судорожные состояния при передозировке, делирий у пожилых пациентов).
|
||||
Учитывая, что СИОЗС обладают лучшей переносимостью по сравнению с ТЦА и редко оказывают свойственные ТЦА антихолинергическое и кардиотоксическое действие, они и другие антидепрессанты новых поколений являются препаратами первого выбора у больных с сердечно-сосудистыми заболеваниями, а также в амбулаторной практике. ТЦА не должны назначаться пациентам с сердечно-сосудистыми заболеваниями умеренной и выраженной тяжести, закрытоугольной глаукомой, гипертрофией простаты, с когнитивными нарушениями, судорожными состояниями и делириозными расстройствами.
|
||||
Учитывая, что СИОЗС обладают лучшей переносимостью по сравнению с ТЦА и редко оказывают свойственные ТЦА антихолинергическое и кардиотоксическое действие, они и другие антидепрессанты новых поколений являются препаратами первого выбора у больных с сердечно-сосудистыми заболеваниями, а также в амбулаторной практике. ТЦА не должны назначаться пациентам с сердечно-сосудистыми заболеваниями умеренной и выраженной тяжести, ⟦стр. 13⟧ закрытоугольной глаукомой, гипертрофией простаты, с когнитивными нарушениями, судорожными состояниями и делириозными расстройствами.
|
||||
|
||||
--- стр. 13 ---
|
||||
|
||||
@@ -383,7 +383,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
, 99], агомелатина** [89, 90] и вортиоксетина [91].
|
||||
Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1).
|
||||
Комментарии: ТЦА при купирующей терапии ДЭ средней степени обычно рассматривают как препараты второго выбора в связи с менее благоприятным профилем переносимости, как отмечалось выше [83, 85]. К препаратам второго выбора также можно отнести тразодон и миансерин. Тразодон является специфическим серотонинергическим АД (в соответствии с АТХ другие антидепрессанты) и, вероятно, обладает сравнимой с СИОЗС эффективностью, но иным профилем побочных эффектов, а именно: при применении тразодона чаще встречаются седация и ортостатическая гипотензия, зато реже возникают сексуальные нарушения (однако в редких случаях возникает тяжелое осложнение в виде приапизма), бессонница, обострение тревоги и тошнота по сравнению с СИОЗС [100, 101]. Миансерин, как и родственный ему миртазапин, по механизму действия относится к норадренергическим и специфическим серотонинергическим антидепрессантам
|
||||
(в соответствии с АТХ - другие антидепрессанты), обладает преимущественно седативным эффектом и имеет довольно слабую доказательную базу, хотя в нескольких публикациях 80-х годов по результатам РКИ сообщалось о его сходной с амитриптилином эффективности [102, 103]. Еще более слабой доказательной базой обладает обладают отечественный антидепрессант трициклической структуры пипофезин** (в соответствии с АТХ - другие антидепрессанты), по которому имеются лишь данные открытых исследований [104]. Эффективность другого отечественного препарата пирлиндола (в соответствии с АТХ - другие антидепрессанты), также изучалась только в открытых исследованиях [105]; кроме того, он, являясь по механизму действия ИМАО-А, может быть эффективен при лечении атипичных депрессий и на стадиях резистентности к терапии [106].
|
||||
(в соответствии с АТХ - другие антидепрессанты), обладает преимущественно седативным эффектом и имеет довольно слабую доказательную базу, хотя в нескольких публикациях 80-х годов по результатам РКИ сообщалось о его сходной с амитриптилином эффективности [102, 103]. Еще более ⟦стр. 14⟧ слабой доказательной базой обладает обладают отечественный антидепрессант трициклической структуры пипофезин** (в соответствии с АТХ - другие антидепрессанты), по которому имеются лишь данные открытых исследований [104]. Эффективность другого отечественного препарата пирлиндола (в соответствии с АТХ - другие антидепрессанты), также изучалась только в открытых исследованиях [105]; кроме того, он, являясь по механизму действия ИМАО-А, может быть эффективен при лечении атипичных депрессий и на стадиях резистентности к терапии [106].
|
||||
|
||||
--- стр. 14 ---
|
||||
|
||||
@@ -411,7 +411,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
133, 134, 135, 136, 137, 138, 139, 140, 141].
|
||||
В настоящее время не существует каких-либо лекарственных препаратов, имеющих официальное разрешение к медицинскому применению при лечении ДЭ со смешанными чертами.
|
||||
Однако большинство АВП, хотя и не все, оказались эффективными при депрессии со смешанными чертами и/или при депрессии [142]. Большинство исследований, изучавших эффективность АВП
|
||||
(атипичных антипсихотических средств) при лечении депрессии со смешанными чертами, проводились у пациентов с БАР, поэтому их результаты должны экстраполироваться на РДР с определенной осторожностью. Имеется также опыт эффективного использования АПП и производных бензодиазепина при лечении депрессии со смешанными чертами. Однако АПП имеют много тяжелых побочных эффектов, а производные бензодиазепина - риск развития зависимости [29].
|
||||
(атипичных антипсихотических средств) при лечении депрессии со смешанными чертами, проводились ⟦стр. 15⟧ у пациентов с БАР, поэтому их результаты должны экстраполироваться на РДР с определенной осторожностью. Имеется также опыт эффективного использования АПП и производных бензодиазепина при лечении депрессии со смешанными чертами. Однако АПП имеют много тяжелых побочных эффектов, а производные бензодиазепина - риск развития зависимости [29].
|
||||
|
||||
--- стр. 15 ---
|
||||
|
||||
@@ -437,7 +437,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
3.2 Купирующая терапия ДЭ на втором этапе
|
||||
Минимум в 30-50 % случаев ДЭ у пациентов не достигается достаточная эффективность любого из выбранных АД при адекватно проведенной терапии первой линии. В такой ситуации необходимо еще раз проверить, правильно ли был поставлен диагноз, достаточна ли доза назначенного препарата и степень комплаентности пациента.
|
||||
В случае отсутствия динамики или недостаточного клинического эффекта (степень редукции симптоматики от 25 % до 50 %) в течение 3-4 недель терапии, либо плохой переносимости антидепрессивной фармакотерапии следует переходить ко второму курсу или этапу терапии.
|
||||
Доказательства обоснования приведенных стратегий весьма ограничены, поскольку РКИ на больших выборках больных не проводилось. В настоящее время нет четкого консенсуса о предпочтительности какой-либо из стратегий при лечении пациентов, не отвечающих на терапию. Особенно это касается препаратов второго выбора. При анализе данных проекта STAR*D было показано, что процент пациентов с ремиссиями можно увеличить с 27 %, достигнутых при использовании терапии первого выбора, до кумулятивного уровня 67 % - после всех четырех последующих шагов терапевтически вмешательств. Однако вероятность достижения ремиссии после первых двух курсов терапии была выше
|
||||
Доказательства обоснования приведенных стратегий весьма ограничены, поскольку РКИ на больших выборках больных не проводилось. В настоящее время нет четкого консенсуса о предпочтительности какой-либо из стратегий при лечении пациентов, не отвечающих на терапию. Особенно это касается препаратов второго выбора. При анализе данных проекта STAR*D было показано, что процент ⟦стр. 16⟧ пациентов с ремиссиями можно увеличить с 27 %, достигнутых при использовании терапии первого выбора, до кумулятивного уровня 67 % - после всех четырех последующих шагов терапевтически вмешательств. Однако вероятность достижения ремиссии после первых двух курсов терапии была выше
|
||||
|
||||
--- стр. 16 ---
|
||||
|
||||
@@ -487,7 +487,7 @@ F33.9 Рекуррентное депрессивное расстройство
|
||||
100, 150, 300 мг/сут. Максимальная доза не должна превышать 600 мг/сут [192, 193]. При этом необходимо учитывать возможность появления седации и набора веса. При применении оланзапина** в целях аугментации эффекта флуоксетина** рекомендуемый диапазон доз составляет 5-10 мг/сут
|
||||
(доза 5 мг вне указанного режима применения препарата в инструкции по медицинскому применению оланзапина**) [211]. Наиболее часто возникают такие побочные эффекты, как увеличение веса, седация и повышение пролактина. При применении #карипразина** диапазон доз варьируется от 1,5 до
|
||||
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 ---
|
||||
|
||||
@@ -529,7 +529,7 @@ L-триптофаном (биологически активная добавк
|
||||
СИОЗС с анксиолитиком азаспиродекандиона производным - буспироном (частичный агонист рецепторов
|
||||
5-НТ1А) [230, 231, 232].
|
||||
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2).
|
||||
● Пациентам с ДЭ с терапевтической резистентностью, у которых терапия первого, второго и третьего этапов оказалась неэффективной, с целью преодоления резистентности рекомендована транскраниальная магнитная стимуляция (ТМС) либо в качестве монотерапии, либо для аугментации эффекта АД [233, 234, 235, 236].
|
||||
● Пациентам с ДЭ с терапевтической резистентностью, у которых терапия первого, второго и третьего этапов оказалась неэффективной, с целью преодоления резистентности рекомендована транскраниальная магнитная стимуляция (ТМС) либо в ⟦стр. 20⟧ качестве монотерапии, либо для аугментации эффекта АД [233, 234, 235, 236].
|
||||
|
||||
--- стр. 20 ---
|
||||
|
||||
@@ -573,7 +573,7 @@ L-триптофаном (биологически активная добавк
|
||||
#карбамазепином** в дозе 200-600 мг/сут [282, 283, 284].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств - 4).
|
||||
Комментарии. Психофармакотерапия была и остается до настоящего времени единственным эффективным методом предотвращения рецидивов РДР. До введения в клиническую практику АД нового поколения для предотвращения рецидивов РДР использовались препараты нормотимического действия. Несмотря на эффективность противорецидивного действия солей лития при РДР в нескольких РКИ [285, 286], в настоящее время более изученными и признанными для профилактики РДР препаратами являются АД. #Карбамазепин** рекомендуется использовать при непереносимости АД и солей лития [17].
|
||||
Уже первые исследования противорецидивной эффективности ТЦА [256, 286] при РДР показали их преимущество перед плацебо. Способность достоверно уменьшать риск рецидива депрессии выявлена также у препарата тетрациклической структуры (с различным механизмом действия) - миансерина [287]. Показано, что снижение дозы уменьшает эффективность профилактической терапии [256, 259]. В то же время, препятствием для длительного использования гетероциклических АД является почти неизбежное развитие у больных побочных эффектов, ограничивающее возможность применения адекватных доз препаратов. ТЦА не рекомендуются для профилактики РДР из-за кардиотоксичности и способности накапливаться в сердечной мышце при длительном приеме. Длительная терапия ТЦА допустима лишь в случае неэффективности других АД и только при условии регулярного контроля переносимости и ЭКГ. В отличие от ТЦА АД нового поколения обладают хорошей переносимостью во всем диапазоне дозировок, что является важным их преимуществом при проведении длительной терапии.
|
||||
Уже первые исследования противорецидивной эффективности ТЦА [256, 286] при РДР показали их преимущество перед плацебо. Способность достоверно уменьшать риск рецидива депрессии выявлена также у препарата тетрациклической структуры (с различным механизмом действия) - миансерина [287]. Показано, что снижение дозы уменьшает эффективность ⟦стр. 22⟧ профилактической терапии [256, 259]. В то же время, препятствием для длительного использования гетероциклических АД является почти неизбежное развитие у больных побочных эффектов, ограничивающее возможность применения адекватных доз препаратов. ТЦА не рекомендуются для профилактики РДР из-за кардиотоксичности и способности накапливаться в сердечной мышце при длительном приеме. Длительная терапия ТЦА допустима лишь в случае неэффективности других АД и только при условии регулярного контроля переносимости и ЭКГ. В отличие от ТЦА АД нового поколения обладают хорошей переносимостью во всем диапазоне дозировок, что является важным их преимуществом при проведении длительной терапии.
|
||||
|
||||
--- стр. 22 ---
|
||||
|
||||
@@ -610,7 +610,7 @@ L-триптофаном (биологически активная добавк
|
||||
307, 308, 311, 312, 313, 314, 315, 316].
|
||||
Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1).
|
||||
Комментарии. Комбинация одного из валидизированных методов психотерапии и фармакотерапии может применяться в качестве инициального лечения пациентов с легкой, умеренной и тяжелой депрессией и имеет ряд преимуществ по сравнению с использованием монотерапии антидепрессантами: улучшение показателей краткосрочного восстановления пациентов, более быстрый терапевтический ответ, снижение частоты рецидивов и выраженности резидуальной депрессивной симптоматики, улучшение долгосрочного социального функционирования, повышение комплаенса, более высокая удовлетворенность пациентов и снижение долгосрочных затрат на здравоохранение и социальные услуги [294, 312, 317, 318].
|
||||
Проведение психотерапии у больных с депрессией является особенно целесообразным при наличии значимых психосоциальных стрессоров, межличностных и семейных конфликтов, сопутствующих личностных нарушений, предпочтений пациента, доступности квалифицированных специалистов, сведений о положительном ответе на определенную психотерапевтическую интервенцию в прошлом [295]. Сравнительное сопоставление различных методов психотерапии затруднительно в связи со значительным многообразием их разновидностей, вариабельностью получаемых результатов, а также прослеживающейся в ряде случаев пристрастностью авторов публикаций [319, 320]. Вместе с тем, по мнению некоторых авторов, ведущую роль в достижении терапевтического эффекта играют не столько специфические особенности метода, сколько неспецифические психологические факторы (например, качество контакта между пациентом и терапевтом, а также ожидания от эффектов терапии со стороны пациента и специалиста, осуществляющего терапию) [321].
|
||||
Проведение психотерапии у больных с депрессией является особенно целесообразным при наличии значимых психосоциальных стрессоров, межличностных и семейных конфликтов, сопутствующих личностных нарушений, предпочтений пациента, доступности квалифицированных специалистов, сведений о положительном ответе на определенную психотерапевтическую интервенцию в прошлом [295]. Сравнительное сопоставление различных методов психотерапии затруднительно в связи со значительным многообразием их разновидностей, вариабельностью получаемых результатов, а также прослеживающейся в ряде случаев пристрастностью авторов публикаций [319, 320]. Вместе с тем, по мнению некоторых авторов, ведущую роль в достижении терапевтического эффекта играют не столько специфические особенности метода, сколько неспецифические психологические факторы (например, качество контакта между пациентом и ⟦стр. 24⟧ терапевтом, а также ожидания от эффектов терапии со стороны пациента и специалиста, осуществляющего терапию) [321].
|
||||
|
||||
--- стр. 24 ---
|
||||
|
||||
@@ -627,7 +627,7 @@ L-триптофаном (биологически активная добавк
|
||||
Комментарии. Психодинамическая психотерапия нацелена на углубленное самопознание пациентом своей психики и причин своего поведения, что приводит к самоосвобождению от симптомов и страданий, связанных с депрессией. Возникающие в процессе терапии инсайты нередко сопровождаются корректирующими переживаниями, которые могут оказывать конструктивное влияние на поведение и стиль реагирования пациента. В центре внимания терапии обычно находятся отношения между пациентом и терапевтом, а также преодоление незрелых защит "Я", искажающих восприятие и переработку информации при депрессии. Основными приемами психодинамической психотерапии являются конфронтация, прояснение, интерпретация и проработка. Их суть заключается в повторяющемся обращении клиента к переживаниям, связанным с прошлым или текущими психологическими проблемами, их переоценке, в привитии навыка аналитического отношения к себе и жизни. Все это способствует развитию у пациента способности вступать в доверительные отношения, формированию более зрелых защит и расширению спектра переживаемых эмоций.
|
||||
● Пациентам с ДЭ или РДР с целью с целью повышения эффективности лечения рекомендуется проведение ТОО путем разъяснения больным роли негативных моделей мышления в возникновении депрессии и формировании у них осознанности на основе медитативных практик [298, 313, 335, 336, 337, 338, 339, 340, 341].
|
||||
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1).
|
||||
Комментарии. По существу ТОО базируется на активном применении медитативных практик, с целью взглянуть на себя и свои психические процессы как бы со стороны, перейдя в метакогнитивную позицию. Указанная позиция позволяет пациенту "растождествиться" с дезадаптивными мыслями, чувствами и реакциями. Этот метод может использоваться более широко, поскольку является более доступным многим пациентам с депрессией, которые испытывают сложности при отслеживании и оспаривании имеющихся у них дисфункциональных когниций.
|
||||
Комментарии. По существу ТОО базируется на активном применении медитативных практик, с целью взглянуть на себя и свои психические процессы как бы со стороны, перейдя в метакогнитивную позицию. Указанная позиция позволяет пациенту "растождествиться" с дезадаптивными мыслями, чувствами и реакциями. Этот метод может использоваться более широко, ⟦стр. 25⟧ поскольку является более доступным многим пациентам с депрессией, которые испытывают сложности при отслеживании и оспаривании имеющихся у них дисфункциональных когниций.
|
||||
|
||||
--- стр. 25 ---
|
||||
|
||||
@@ -652,7 +652,7 @@ L-триптофаном (биологически активная добавк
|
||||
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 1).
|
||||
Комментарий: Применение светотерапии целесообразно прежде всего при сезонном аффективном расстройстве - подтипе РДР, при котором выявляется зависимость депрессивных эпизодов от времени года, обычно возникающих в осенне-зимний период.
|
||||
- Депривация сна [356, 357];
|
||||
Уровень убедительности рекомендаций B (уровень достоверности доказательств - 2).
|
||||
Уровень убедительности рекомендаций B (уровень достоверности ⟦стр. 26⟧ доказательств - 2).
|
||||
|
||||
--- стр. 26 ---
|
||||
|
||||
@@ -687,7 +687,7 @@ L-триптофаном (биологически активная добавк
|
||||
● Всем пациентам с ДЭ или РДР рекомендуется проведение различных методов психотерапии (см. Подраздел 3.7. Иное лечение. Психотерапия) с целью поддержания ремиссии, снижения интенсивности рецидивирования и улучшения социального функционирования больных на профилактическом этапе лечения [294, 298, 303, 313, 314,
|
||||
334, 337].
|
||||
Уровень убедительности рекомендаций A (уровень достоверности доказательств - 1).
|
||||
● Всем пациентам, а также родственникам пациентов с целью повышения осведомленности о природе заболевания, его течении, прогнозе и методах лечения рекомендуется применение групповой психообразовательной работы с больными с психическими расстройствами и расстройствами поведения и групповой психообразовательной работы с родственниками больного с психическими расстройствами и расстройствами поведения [240, 372, 373].
|
||||
● Всем пациентам, а также родственникам пациентов с целью повышения осведомленности о природе заболевания, его течении, прогнозе и методах лечения рекомендуется применение групповой психообразовательной работы с больными с психическими расстройствами и расстройствами поведения и групповой психообразовательной работы с родственниками больного с психическими ⟦стр. 27⟧ расстройствами и расстройствами поведения [240, 372, 373].
|
||||
|
||||
--- стр. 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.
|
||||
2018; 98(8):508-515.
|
||||
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 ---
|
||||
|
||||
@@ -822,7 +822,7 @@ JM, Danchin N, Limosin F, Lemogne C. Association between depression and anemia i
|
||||
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.
|
||||
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 ---
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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 ---
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
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 ---
|
||||
|
||||
@@ -983,7 +983,7 @@ Psychopharmacol. 2015. 29(5): 459-525.
|
||||
Evidence-based guidelines for treating bipolar disorder: revised second edition. Recommendations from the
|
||||
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
|
||||
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 ---
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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 ---
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
229. Прохорова И.С. Некоторые теоретические и практические аспекты применения одномоментной отмены психотропных средств. Автореферат дисс канд. мед. наук. М., 1975; 22 с.
|
||||
229. Прохорова И.С. Некоторые теоретические и практические аспекты применения ⟦стр. 39⟧ одномоментной отмены психотропных средств. Автореферат дисс канд. мед. наук. М., 1975; 22 с.
|
||||
|
||||
--- стр. 39 ---
|
||||
|
||||
@@ -1237,7 +1237,7 @@ Assessment, Impact, and Management. Focus (Am Psychiatr Publ); 14(2):194-206. do
|
||||
2021; 40 с.
|
||||
272. Ахапкин Р.В. Когнитивные нарушения при депрессивных расстройствах: анализ проблемы и перспективы решения (обзор литературы)//Современная Терапия Психических Расстройств. 2015. Т.
|
||||
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 ---
|
||||
|
||||
@@ -1325,7 +1325,7 @@ Driel ML. The effects of social group interventions for depression: Systematic r
|
||||
49(6):361-72.
|
||||
319. Kaiser T. et al. Heterogeneity of Treatment Effects in Trials on Psychotherapy of Depression//Clin.
|
||||
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 ---
|
||||
|
||||
@@ -1421,7 +1421,7 @@ B.C.
|
||||
Г.П.,
|
||||
Цуцульковская М.Я. Аффективные и шизоаффективные психозы: Материалы науч.-практ. конф. с междунар. участием. 7-8 апреля 1998 г.; с. 386-390.
|
||||
367. Кекелидзе З.И., Тваладзе М.Г., Чечелашвили А.П. и соавт. Роль и место инфузий облученной ультрафиолетом аутокрови в лечении депрессивных состояний. Социальная и клиническая психиатрия 1993; N 3: с. 88-94.
|
||||
368. Каримулаев И.А., Калинин В.В., Мосолов С.Н. Применение метода адаптации к периодической нормобарической гипоксии в лечении фармакорезистентных депрессий. В книге: Новые достижения в терапии психических заболеваний./ред. С.Н. Мосолов/- М., 2002; c. 579-592.
|
||||
368. Каримулаев И.А., Калинин В.В., Мосолов С.Н. Применение метода адаптации к периодической нормобарической гипоксии в лечении фармакорезистентных депрессий. В книге: Новые ⟦стр. 45⟧ достижения в терапии психических заболеваний./ред. С.Н. Мосолов/- М., 2002; c. 579-592.
|
||||
|
||||
--- стр. 45 ---
|
||||
|
||||
@@ -1525,7 +1525,7 @@ Family Physician. - 2006. - Vol. 74 (3). - Pp. 449-456.
|
||||
Минюсте России 14.11.2022 N 70940).
|
||||
4. Приказ Минздрава России от 30.06.2022 N 453н "Об утверждении Порядка диспансерного наблюдения за лицом, страдающим хроническим и затяжным психическим расстройством с тяжелыми стойкими или часто обостряющимися болезненными проявлениями"(Зарегистрировано в Минюсте
|
||||
России 28.07.2022 N 69429).
|
||||
5. Приказ Министерства Здравоохранения Российской Федерации от 20.12.2012 N 1219н "Об утверждении стандарта первичной медико-социальной помощи при депрессиях легкой и средней степени тяжести и смешанном тревожном и депрессивном расстройстве в амбулаторных условиях психоневрологического диспансера (диспансерного отделения, кабинета)".
|
||||
5. Приказ Министерства Здравоохранения Российской Федерации от 20.12.2012 N 1219н "Об утверждении стандарта первичной медико-социальной помощи при депрессиях легкой и средней ⟦стр. 48⟧ степени тяжести и смешанном тревожном и депрессивном расстройстве в амбулаторных условиях психоневрологического диспансера (диспансерного отделения, кабинета)".
|
||||
|
||||
--- стр. 48 ---
|
||||
|
||||
@@ -1961,7 +1961,7 @@ Mosolov S.N., Yaltonskaya P.A., Senko O.V., Angst J. Validation of the Russian v
|
||||
□
|
||||
□
|
||||
2) Как Вы обычно оцениваете себя по сравнению с другими людьми?
|
||||
Вне зависимости от того, как Вы чувствуете себя сейчас, укажите, как вы обычно воспринимаете себя по другими людьми. Выберите наиболее подходящую формулировку из перечисленных ниже:
|
||||
Вне зависимости от того, как Вы чувствуете себя сейчас, укажите, как вы обычно воспринимаете ⟦стр. 62⟧ себя по другими людьми. Выберите наиболее подходящую формулировку из перечисленных ниже:
|
||||
|
||||
--- стр. 62 ---
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ ID: 753
|
||||
# – в инструкции по применению не указано данное заболевание или расстройство
|
||||
Термины и определения
|
||||
Травма – событие, связанное с мощным психотравмирующим воздействием и сопровождающимся стрессом экстремального характера.
|
||||
Стресс – совокупность неспецифических адаптационных (нормальных) реакций организма на воздействие различных неблагоприятных факторов-стрессоров (физических или психологических), нарушающее его гомеостаз, а также соответствующее состояние нервной системы организма (или организма в целом). В медицине, физиологии, психологии выделяют положительную (эустресс от др. греч.
|
||||
Стресс – совокупность неспецифических адаптационных (нормальных) реакций ⟦стр. 2⟧ организма на воздействие различных неблагоприятных факторов-стрессоров (физических или психологических), нарушающее его гомеостаз, а также соответствующее состояние нервной системы организма (или организма в целом). В медицине, физиологии, психологии выделяют положительную (эустресс от др. греч.
|
||||
|
||||
--- стр. 2 ---
|
||||
|
||||
@@ -62,7 +62,7 @@ ID: 753
|
||||
ПТСР развивается вследствие неспособности индивида справиться с экзогенным психотравмирующим воздействием чрезвычайного характера. В настоящее время активно исследуется вклад генетических факторов, в том числе с целью поиска возможных новых препаратов для лечения ПТСР, на сегодняшний день имеется большая база генетических исследований, которые указывают на полигенный характер фенотипа ПТСР, при этом вопрос о соотношении собственно генетических и средовых факторов остается открытым. Хорошо известно, что предшествующая травматизация повышает риск развития ПТСР, но пока нет однозначного описания патогенетических механизмов подобной сенситизации [3, 4, 5, 6].
|
||||
Острые стрессовые реакция являются нормальным ответом на чрезвычайное воздействия у ряда людей. Из-за дефицита ресурсов адаптабельности естественный ответ на стресс принимает патологический характер, в частности нарушается функционирование гипоталамо-гипофизарно-надпочечниковой оси и симпатоадреналовой системы, что приводит к манифестации симптомов ПТСР, повышенной готовности к тревожным реакциям, физиологическому возбуждению и гипервигилитету [7, 8, 9, 10, 11, 12, 13].
|
||||
В когнитивных моделях ПТСР, которые в настоящее время нашли свое подтверждение в исследованиях с использованием современных возможностей нейровизуализации, патогенез ПТСР тесно связан с нарушением процессов переработки информации и интеграции травматического опыта в общую биографическую память пациента. Было выявлено, что травматические воспоминания ассоциированы со сниженной активностью левой нижней фронтальной коры, что дает возможность предположить, что в этих воспоминаниях преобладают эмоциональные и сенсорные элементы без соответствующих лингвистических ассоциаций.
|
||||
Информация о травме существует в активной рабочей памяти до тех пор, пока не произойдет ее осмысление. Таким образом формируется цикл оживления травматических воспоминаниях под воздействием триггеров, связанных с травмой и стремлением избегать неприятный опыт с попытками контролировать ход мыслей и избегать столкновения со всем, что может напоминать о травме, что в свою очередь ухудшает переработку травматического опыта. В норме эпизоды навязчивых наплывов травматических переживаний чередуются с периодами избегания, со временем по мере завершения процесса эти состояния становятся реже и менее интенсивными. В ряде случаев может возникать конфликт между потребностью в осмыслении и существующей системой отношений, в этом случае возникает конфликт с последующим развитием избегающего поведения, эмоционального онемения, как проявления психологической защиты, что формирует почву для последующей манифестации коморбидных депрессивных расстройств. В этих случаях аффект утрачивает свою сигнальную функцию, поскольку любая сильная эмоция воспринимается как опасность возвращения травматического опыта [17, 18, 19, 20].
|
||||
Информация о травме существует в активной рабочей памяти до тех пор, пока не ⟦стр. 4⟧ произойдет ее осмысление. Таким образом формируется цикл оживления травматических воспоминаниях под воздействием триггеров, связанных с травмой и стремлением избегать неприятный опыт с попытками контролировать ход мыслей и избегать столкновения со всем, что может напоминать о травме, что в свою очередь ухудшает переработку травматического опыта. В норме эпизоды навязчивых наплывов травматических переживаний чередуются с периодами избегания, со временем по мере завершения процесса эти состояния становятся реже и менее интенсивными. В ряде случаев может возникать конфликт между потребностью в осмыслении и существующей системой отношений, в этом случае возникает конфликт с последующим развитием избегающего поведения, эмоционального онемения, как проявления психологической защиты, что формирует почву для последующей манифестации коморбидных депрессивных расстройств. В этих случаях аффект утрачивает свою сигнальную функцию, поскольку любая сильная эмоция воспринимается как опасность возвращения травматического опыта [17, 18, 19, 20].
|
||||
|
||||
--- стр. 4 ---
|
||||
|
||||
@@ -122,7 +122,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
|
||||
Диагноз включает дополнения к трем типичным кластерам симптомов ПТСР:
|
||||
1. Стойкие длительные нарушения в аффективной сфере (повышенная эмоциональная реактивность, отсутствие эмоций, развитие диссоциативных состояний);
|
||||
2. Поведенческие нарушения (вспышки ярости, безрассудное или саморазрушающее поведение);
|
||||
3. Изменения в сфере представлений о самом себе (стойкие негативные представления о себе, как об униженном, побежденном и ничего не стоящем человеке, которые могут сопровождаться глубокими и всеохватывающими чувствами стыда, вины или несостоятельности);
|
||||
3. Изменения в сфере представлений о самом себе (стойкие негативные представления о себе, как об униженном, побежденном и ничего не стоящем человеке, которые могут ⟦стр. 7⟧ сопровождаться глубокими и всеохватывающими чувствами стыда, вины или несостоятельности);
|
||||
|
||||
--- стр. 7 ---
|
||||
|
||||
@@ -142,7 +142,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
|
||||
1. Повторяющиеся переживания травмирующего события (флэшбеки, представленные в навязчивых реминисценциях, ярких навязчивых воспоминаниях или повторяющихся снах). В большинстве случаев они неприятны и аффективно насыщенны [1, 15, 16].
|
||||
2. Избегание действий и ситуаций, напоминающих о травме (фактическое избегание, чувство "онемения" и эмоционального притупления, отрешенности, невосприимчивости к окружающему, эмоциональная отстраненность, ангедония, ограничительное поведение) [1,
|
||||
15, 16, 33].
|
||||
3. Чрезмерное физиологическое возбуждение (бессонница, связанная с наплывом неприятных воспоминаний о травматическом событии, гипервигилитет сверхнастороженность, повышенная реакция испуга) [1, 14, 15, 16, 33].
|
||||
3. Чрезмерное физиологическое возбуждение (бессонница, связанная с наплывом ⟦стр. 8⟧ неприятных воспоминаний о травматическом событии, гипервигилитет сверхнастороженность, повышенная реакция испуга) [1, 14, 15, 16, 33].
|
||||
|
||||
--- стр. 8 ---
|
||||
|
||||
@@ -165,7 +165,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
|
||||
6. Сниженная выраженность аффекта (неспособность, например, к чувству любви).
|
||||
7. Чувство отсутствия перспективы в будущем (например, отсутствие ожиданий по поводу карьеры, женитьбы, детей или пожелания долгой жизни).
|
||||
В целом ПТСР вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности.
|
||||
В динамике развития расстройств на первом этапе ПТСР пациент погружен в мир переживаний, связанных с травмой, отмечаются наиболее выраженные в этот период флэшбеки. На этом этапе нередко отмечается обострение (или диагностируются впервые) хронических соматических заболеваний, особенно тех, в основе которых участвуют психосоматические механизмы формирования (язвенная болезнь двенадцатиперстной кишки и желудка, холецистит, холангит, колит, запоры, бронхиальная астма и др.). Отмечаются также сексологические нарушения: снижение либидо и эрекции. Стоит отметить, что специфические симптомы, проявляющиеся в чувстве беспомощности, растерянности, вторгающихся пугающих образов, стойких руминациях о прогрессировании заболевания, могут в значительной степени влиять на совладание с соматическим заболеванием.
|
||||
В динамике развития расстройств на первом этапе ПТСР пациент погружен в мир ⟦стр. 9⟧ переживаний, связанных с травмой, отмечаются наиболее выраженные в этот период флэшбеки. На этом этапе нередко отмечается обострение (или диагностируются впервые) хронических соматических заболеваний, особенно тех, в основе которых участвуют психосоматические механизмы формирования (язвенная болезнь двенадцатиперстной кишки и желудка, холецистит, холангит, колит, запоры, бронхиальная астма и др.). Отмечаются также сексологические нарушения: снижение либидо и эрекции. Стоит отметить, что специфические симптомы, проявляющиеся в чувстве беспомощности, растерянности, вторгающихся пугающих образов, стойких руминациях о прогрессировании заболевания, могут в значительной степени влиять на совладание с соматическим заболеванием.
|
||||
|
||||
--- стр. 9 ---
|
||||
|
||||
@@ -223,7 +223,7 @@ F43 "Реакция на тяжелый стресс и нарушения ад
|
||||
Комментарии: В МКБ-10 выделены следующие критерии ПТСР:
|
||||
А. Больной должен быть подвержен воздействию стрессорного события или ситуации
|
||||
(как коротко, так и длительно длящихся) исключительно угрожающего или катастрофического характера, что способно вызвать общий дистресс почти у любого индивидуума.
|
||||
Б. стойкие навязчивые воспоминания или "оживление стрессора в навязчивых реминисценциях, ярких воспоминаниях или повторяющихся снах, либо повторные переживания горя при воздействии обстоятельств, напоминающих или ассоциирующихся со стрессором.
|
||||
Б. стойкие навязчивые воспоминания или "оживление стрессора в навязчивых реминисценциях, ярких воспоминаниях или повторяющихся снах, либо повторные ⟦стр. 11⟧ переживания горя при воздействии обстоятельств, напоминающих или ассоциирующихся со стрессором.
|
||||
|
||||
--- стр. 11 ---
|
||||
|
||||
@@ -353,7 +353,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
Более полно клинико-психологическая и экспериментально-психологическая диагностика может проводиться медицинским психологом в зависимости от конкретных диагностических задач или для определения мишеней психотерапевтического воздействия. Выбор методов и методик психологической диагностики относится к компетенции медицинского психолога, при соблюдении требования о представлении в тексте психологического заключения испрашиваемых лечащим врачом данных. Могут применяться различные шкалы, прошедшие адаптацию и валидизацию, в зависимости от целей исследования. Методики могут применяться факультативно, и перечень возможных методик для проведения экспериментально-психологического обследования не ограничен. При этом одной из важнейших составляющих участия медицинского психолога в оказании медицинской помощи пациентам с ПТСР является психологическая диагностика. Используются все основные виды, методы и методики, принятые как в психологической диагностике в целом, так и в ее прикладном клиническом разделе, именуемом "медицинской психодиагностикой" или "психологической диагностикой в клинике". В соответствии с базовыми теоретическими положениями психологической диагностики, в клинической практике используются и клинико-психологические (экспертные) методы (клиническая беседа, наблюдение, интервью, анализ продуктов деятельности и т.п.), и экспериментально психологические методы.
|
||||
Опросниковые методы опираются в значительной степени на самооценочную природу получаемых данных и имеют свои ограничения в части объективности таких данных. С другой стороны, они в большей степени отражают индивидуально-психологические особенности самой личности и ее сохранность/измененность в ситуации болезни, а также раскрывают широкий комплекс присущих испытуемому связей, отношений, способов реагирования и совладания, иных клинически значимых характеристик личности и личностно-средового взаимодействия и др. Такие данные должны расцениваться в рамках персонализированного подхода при реализации биопсихосоциальной парадигмы современных антропоцентрированных и клинических наук. При этом клинические методы органично вплетены в процедуры экспериментально-психологического исследования и используются непосредственно в процессе выполнения испытуемым проб и тестовых методик Отличительной особенностью применения клинико- и экспериментальнопсихологических методов при решении задач психологической диагностики является учет и анализ специфики, обусловленной измененным болезнью психическим и психологическим статусом испытуемого.
|
||||
3. Лечение, включая медикаментозную и немедикаментозную терапии, диетотерапию, обезболивание, медицинские показания и противопоказания к применению методов лечения
|
||||
ПТСР – заболевание, которое имеет высокую коморбидностъ с большим количеством психических и соматических расстройств. В лечении пациентов с ПТСР и коморбидными нарушениями целесообразно также опираться на клинические рекомендации и стандарты оказания помощи для этих заболеваний.
|
||||
ПТСР – заболевание, которое имеет высокую коморбидностъ с большим количеством психических и соматических расстройств. В лечении пациентов с ПТСР и коморбидными ⟦стр. 17⟧ нарушениями целесообразно также опираться на клинические рекомендации и стандарты оказания помощи для этих заболеваний.
|
||||
|
||||
--- стр. 17 ---
|
||||
|
||||
@@ -383,7 +383,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- диазепам** 5-15 мг/сут (при психомоторном возбуждении, связанном с тревогой, коморбидных острых тревожно-фобических, тревожно-депрессивных состояниях). Дозу обычно делят на 2-3 приема. Длительность курса диазепама** не должна превышать 4 недель, в тяжелых случаях допускается до 8-12 недель, включая время постепенного прекращения [57, 303, 304, 307, 308].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
|
||||
- лоразепам** 2-10 мг/сут (при тревоге, раздражительности, нарушениях сна, вегетативных нарушениях). Обычно назначают 2-3 мг/сут, разделенные на 1-3 приема, а затем при необходимости, увеличивают суточную дозу препарата до поддерживающей, которая чаще всего составляет от 2 мг до 6 мг в сутки, разделенная на 1-3 приема. Для купирования симптомов рекомендуется применять как можно меньшие эффективные дозы.
|
||||
Дозу лоразепама** необходимо увеличивать постепенно, начиная с увеличения дозы, применяемой вечером. Максимально можно назначить до 10 мг в сутки. При нарушениях сна, вызванных тревогой, обычно назначают раз в сутки перед сном от 2 до 5 мг лоразепама** [57, 303, 309].
|
||||
Дозу лоразепама** необходимо увеличивать постепенно, начиная с увеличения дозы, применяемой вечером. Максимально можно назначить до 10 мг в сутки. При нарушениях сна, вызванных тревогой, обычно назначают раз в сутки перед сном от 2 до 5 мг ⟦стр. 19⟧ лоразепама** [57, 303, 309].
|
||||
|
||||
--- стр. 19 ---
|
||||
|
||||
@@ -413,7 +413,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
(15-80 мг/сут) для купирования этих проявлений [67, 68].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
|
||||
Комментарии: курсовое лечение необходимо начинать с приема 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 ---
|
||||
|
||||
@@ -427,7 +427,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
(симптоматическое лечение тревоги). Он не угнетает кору головного мозга, но его действие может быть связано с подавлением активности некоторых ключевых зон субкортикальной области центральной нервной системы. В случае применения пероральных лекарственных форм седативный эффект наступает через 30-45 минут. Гидроксизин** также обладает спазмолитическим и симпатолитическим действием, демонстрирует умеренную анальгетическую активность. Рекомендуемая дозировка 25-100 мг/сут. Поскольку реакции на прием гидроксизина** отличаются изменчивостью, рекомендуется начинать лечение с низких доз препарата и постепенно повышать до оптимальной, регулируя ее в соответствии с ответом пациента на терапию. В случае если тревожность проявляется бессонницей, основная доза принимается вечером перед сном. Длительность определяется индивидуально, курс обычно составляет 4-8 недель. Препарат может применяться в комбинированной терапии с другими средствами.
|
||||
- Пациентам с ПТСР рекомендуется применение анксиолитиков, не относящихся к группе производные бензодиазепина – буспирона (20-30 мг/сут) при наличии коморбидных тревожных расстройств для снижения уровня тревоги [75, 78].
|
||||
Уровень убедительности рекомендаций 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 ---
|
||||
|
||||
@@ -513,7 +513,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- Пациентам с ПТСР рекомендовано использование когнитивной психотерапии длительностью 15-20 сессий, которые проводятся еженедельно индивидуально и/или в группе для модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию [130, 131].
|
||||
Уровень убедительности рекомендаций A (уровень достоверности доказательств – 2).
|
||||
Комментарии: основная задача терапии – модификация пессимистических и катастрофических оценок и воспоминаний, связанных с психотравмой, с целью преодоления поведенческих и когнитивных паттернов, поддерживающих избегание и препятствующих нормальному повседневному функционированию.
|
||||
Пациент под руководством психотерапевта обучается идентификации внутренних и внешних стимулов, а также специфических триггеров, поддерживающих симптомы ПТСР. С целью уменьшения выраженности интрузий проводится тщательная оценка воспоминаний и интегрирование травматического опыта. Для работы с дисфункциональными мыслями, связанными с оценкой травмы и глубинными убеждениями, поддерживающими ощущения постоянной угрозы, рекомендован сократовский диалог. Дополнительной мишенью являются дисфункциональные когнитивные и поведенческие паттерны, которые блокируют адаптивные копинг-стратегии и восстановление последовательных воспоминаний о травматическом событии, к примеру, руминации, поиск безопасности, подавление мыслей.
|
||||
Пациент под руководством психотерапевта обучается идентификации внутренних и внешних стимулов, а также специфических триггеров, поддерживающих симптомы ПТСР. С целью уменьшения выраженности интрузий проводится тщательная оценка воспоминаний и интегрирование травматического опыта. Для работы с дисфункциональными мыслями, связанными с оценкой травмы и глубинными убеждениями, поддерживающими ощущения постоянной угрозы, рекомендован сократовский диалог. Дополнительной мишенью являются дисфункциональные когнитивные и поведенческие паттерны, которые блокируют ⟦стр. 27⟧ адаптивные копинг-стратегии и восстановление последовательных воспоминаний о травматическом событии, к примеру, руминации, поиск безопасности, подавление мыслей.
|
||||
|
||||
--- стр. 27 ---
|
||||
|
||||
@@ -528,7 +528,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- Пациентам с ПТСР рекомендовано использование индивидуальной когнитивноповеденческой психотерапии с пролонгированной экспозицией – 15-20 сеансов с целью повышения переносимости неприятных стимулов, связанных с травматическим опытом, обучения пациентов постепенно соприкоснуться к чувствам, воспоминаниям и ситуациям, связанных с травмой [140, 141, 142, 143, 144, 145, 146, 147, 148].
|
||||
Уровень убедительности рекомендаций 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 ---
|
||||
|
||||
@@ -541,7 +541,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
Комментарии: само название определяет основную цель терапии – формирование альтернативной оценки травматического опыта, который часто пациентами однозначно воспринимается как невыносимый и безысходный, поведенческий модуль направлен на выработку оптимальных паттернов поведения в процессе сопоставления различных, иногда противоречивых вариантов. Задача психотерапевта в каждом индивидуальном случае найти оптимальный баланс между принятием и изменением, для чего используются соответствующие техники, для решения отдельных задач могут дополнительно использоваться отдельные приемы ТФ-КПТ, психотерапии, сфокусированной на сострадании, психотерапии принятия и ответственности. В процессе диалектической бихевиоральной терапии ПТСР используются майндфуллнесс, обучение переносимости дистресса и навыкам эмоциональной регуляции, повышение межличностной эффективности, экспозиция и предотвращение ответа, противоположное поведение, валидация, самопринятие.
|
||||
- Пациентам с ПТСР рекомендовано использование "Десенсибилизации и переработки движениями глаз" (ДПДГ), длительностью 6-12 сессий для переработки травматического опыта [155, 156, 157, 158, 159, 160].
|
||||
Уровень убедительности рекомендаций A (уровень достоверности доказательств 2).
|
||||
Комментарии: "Десенсибилизации и переработки движениями глаз" (ДПДГ) – метод конфронтации с травматическим опытом, с применением направленной билатеральной стимуляции (посредством ритмичных движений глазами) с одновременным образным представлением травматического события. Движения глаз и другие формы стимуляции двойного фокуса внимания, помимо глазных движений возможно использование звуковой стимуляции или постукивание по различным частям тела, обеспечивает одновременную десенсибилизацию и когнитивное переструктурирование, а также интеграцию травматических воспоминаний и уменьшение выраженности симптомов ПТСР. Это метод индивидуальной психотерапии длительностью 6-12 сессий, которые могут проводиться последовательно каждый день. Ф. Шапиро – автор метода основывается на том, что эмоциональная травма может нарушить работу системы переработки информации, поэтому она будет сохраняться в форме, обусловленной травматическим переживанием, и способствует формированию интрузионных симптомов посттравматического синдрома.
|
||||
Комментарии: "Десенсибилизации и переработки движениями глаз" (ДПДГ) – метод конфронтации с травматическим опытом, с применением направленной билатеральной стимуляции (посредством ритмичных движений глазами) с одновременным образным представлением травматического события. Движения глаз и другие формы стимуляции ⟦стр. 29⟧ двойного фокуса внимания, помимо глазных движений возможно использование звуковой стимуляции или постукивание по различным частям тела, обеспечивает одновременную десенсибилизацию и когнитивное переструктурирование, а также интеграцию травматических воспоминаний и уменьшение выраженности симптомов ПТСР. Это метод индивидуальной психотерапии длительностью 6-12 сессий, которые могут проводиться последовательно каждый день. Ф. Шапиро – автор метода основывается на том, что эмоциональная травма может нарушить работу системы переработки информации, поэтому она будет сохраняться в форме, обусловленной травматическим переживанием, и способствует формированию интрузионных симптомов посттравматического синдрома.
|
||||
|
||||
--- стр. 29 ---
|
||||
|
||||
@@ -654,7 +654,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
Профилактика ПТСР заключается во внедрении принципов здорового образа жизни, мероприятий, направленных на поддержание психического здоровья среди населения, ограничение употребления алкоголя и психоактивных веществ.
|
||||
а) Непсихологические и немедикаментозные профилактические мероприятия для взрослых.
|
||||
В области психического здоровья в отечественной практике выстраивание системы профилактики принимает форму психологического просвещения населения.
|
||||
Психологическое просвещение является активной формой психологической профилактики, основными задачами которой являются:
|
||||
Психологическое просвещение является активной формой психологической ⟦стр. 34⟧ профилактики, основными задачами которой являются:
|
||||
|
||||
--- стр. 34 ---
|
||||
|
||||
@@ -672,7 +672,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
|
||||
Комментарии: Меры вторичной профилактики могут быть реализованы после воздействия травмирующего фактора (наступления травмирующего события), отвечающего критериям, потенциально влекущим формирование ПТСР (внезапность, субъективно переживаемая угроза жизни, утрата). В отечественной практике вторичную профилактику условно можно обозначить двумя направлениями частично взаимосвязанными друг с другом содержанием и являющимися при этом универсальными рекомендациями для переживших травматическое событие: экстренная психологическая помощь и психологическое просвещение. Экстренная психологическая помощь (ЭПП) включает в себя также информационно-психологическую поддержку, индивидуальную, психологическую помощь.
|
||||
Экстренная психологическая помощь (Emergency psychological aid) – целостная система мероприятий, направленных на оптимизацию актуального психического состояния, включающая в себя как отдельные специальные методы психологического воздействия, так и организацию особой среды, которая помогает в снижении интенсивности воздействия травматического события на человека. ЭПП может быть оказана человеку во времени максимально приближенно от момента воздействия травмирующего события.
|
||||
Задачи такого вида психологической помощи включают в себя удовлетворение актуальных потребностей человека (информация, социальная поддержка и т.д.), в том числе переживание травматического опыта, а в качестве цели ставит сохранение психологических и физических ресурсов человека, переживающего травматическое событие [224, 225].
|
||||
Задачи такого вида психологической помощи включают в себя удовлетворение актуальных потребностей человека (информация, социальная поддержка и т.д.), в том числе переживание травматического опыта, а в качестве цели ставит сохранение психологических ⟦стр. 35⟧ и физических ресурсов человека, переживающего травматическое событие [224, 225].
|
||||
|
||||
--- стр. 35 ---
|
||||
|
||||
@@ -687,7 +687,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
218, 219, 220, 228, 229].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
|
||||
Комментарии: индивидуальная психологическая помощь (ИПП) представляет собой форму работы, направленную на актуализацию личностных ресурсов, принятие сложившейся ситуации, поиск и актуализацию стратегий совладания, регуляцию актуального психического состояния. Одним из инструментов ЭПП является фасилитация эмоционального отреагирования человека относительно произошедшего травматического события. Номинация эмоционального состояния, которая происходит в ходе оказания ЭПП, позволяет снизить влияние травматических переживаний на состояние человека непосредственно после произошедшего, актуализировать ресурсы, необходимые для совладания со сложившейся ситуацией [228].
|
||||
Также сохранение социальной активности, выстраивание сети социальной поддержки и актуализация социальных связей может быть обоснованной профилактической практикой для людей, переживающих травматическое событие, особенно в контексте изменения базовых позитивных убеждений в отношении мира и окружения. Обнаружены свидетельства о положительном влиянии социальной поддержки, в том числе и в виде отношений с близкими, на способность человека справляться с травматической ситуацией [216, 217, 218].
|
||||
Также сохранение социальной активности, выстраивание сети социальной поддержки и актуализация социальных связей может быть обоснованной профилактической практикой для людей, переживающих травматическое событие, особенно в контексте изменения ⟦стр. 36⟧ базовых позитивных убеждений в отношении мира и окружения. Обнаружены свидетельства о положительном влиянии социальной поддержки, в том числе и в виде отношений с близкими, на способность человека справляться с травматической ситуацией [216, 217, 218].
|
||||
|
||||
--- стр. 36 ---
|
||||
|
||||
@@ -704,7 +704,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- В отношении лиц, столкнувшихся с чрезвычайным, катастрофическим стрессом, рекомендуется проводить психологические интервенции с целью снижения риска ПТСР
|
||||
[224, 225, 226, 227].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
|
||||
Комментарии: Одной из самых сложных задач является выявление людей, нуждающихся в помощи, потому что взаимосвязь между острыми и хроническими стрессовыми реакциями сложна и обуславливается множеством факторов, которые не могут быть с точностью проиндексированы в острой фазе. Проблемы, вызванные переживанием боли, потерей дохода, прерыванием трудовой деятельности или смертью близкого, общее социальное неблагополучие, которое часто сопровождает травмирующее событие, могут отвлечь человека от предложения провести интервенцию. Лишь небольшая часть остро травмированных людей на самом деле принимает предложения о лечении [235]. В связи с этим особо важное значение имеет необходимость постоянного мониторинга некоторых подгрупп после травмы вместо того, чтобы делать акцент на раннем вмешательстве. Тем не менее существует несколько подходов, принятых эффективными на ранних этапах вмешательства.
|
||||
Комментарии: Одной из самых сложных задач является выявление людей, нуждающихся в помощи, потому что взаимосвязь между острыми и хроническими стрессовыми реакциями сложна и обуславливается множеством факторов, которые не могут быть с точностью проиндексированы в острой фазе. Проблемы, вызванные переживанием боли, потерей дохода, прерыванием трудовой деятельности или смертью близкого, общее социальное неблагополучие, которое часто сопровождает травмирующее событие, могут отвлечь человека от предложения провести интервенцию. Лишь небольшая часть остро травмированных людей на самом деле принимает предложения о лечении [235]. В связи с этим особо важное значение имеет необходимость постоянного мониторинга некоторых подгрупп после травмы вместо того, чтобы делать акцент на раннем вмешательстве. Тем не ⟦стр. 37⟧ менее существует несколько подходов, принятых эффективными на ранних этапах вмешательства.
|
||||
|
||||
--- стр. 37 ---
|
||||
|
||||
@@ -729,7 +729,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
1. Принцип раннего выявления психических расстройств:
|
||||
Способность оказать своевременную психотерапевтическую и психиатрическую помощь на ранних этапах развития психических нарушений зависит от того, насколько быстро выявляются лица, пострадавшие в ходе чрезвычайной ситуации. Максимально раннее выявление лиц, не только с уже сформировавшимися психическими расстройствами, но и нарушениями субклинического уровня, предотвращает углубление психических расстройств и их хронификацию, способствует профилактике развития социальной дезадаптации. Раннему выявлению также способствует широкое информирование населения о причинах и клинических проявлениях ПТСР, об учреждениях, оказывающих медицинскую и психологическую помощь, о возможностях обращать по телефонам "горячей линии".
|
||||
2. Принцип доступности медико-психологической помощи:
|
||||
В условиях чрезвычайной ситуации (ЧС) резко увеличивается число нуждающихся в помощи при ограниченном количестве специалистов, способных оказать необходимую помощь. Целесообразно организовывать "горячие линии". Куда могут обращаться как пациенты, так и специалист, получать психологическую помощи и информацию об организации медицинской и психологической помощи при ПТСР, учреждениях ее оказывающих. При этом помощь должна носить не разовый, а постоянный характер с учетом возможности длительного течения психических расстройств. Наиболее оптимальной представляется организация медико-психиатрической
|
||||
В условиях чрезвычайной ситуации (ЧС) резко увеличивается число нуждающихся в помощи при ограниченном количестве специалистов, способных оказать необходимую ⟦стр. 38⟧ помощь. Целесообразно организовывать "горячие линии". Куда могут обращаться как пациенты, так и специалист, получать психологическую помощи и информацию об организации медицинской и психологической помощи при ПТСР, учреждениях ее оказывающих. При этом помощь должна носить не разовый, а постоянный характер с учетом возможности длительного течения психических расстройств. Наиболее оптимальной представляется организация медико-психиатрической
|
||||
|
||||
--- стр. 38 ---
|
||||
|
||||
@@ -745,7 +745,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
Основная цель психотерапии лиц с ПТСР, длительное время находившихся в условиях
|
||||
ЧС, заключается в восстановлении удовлетворительного уровня функционирования. Для этого необходимы некоторые дополнительные ресурсы "Я" или более гибкие эмоциональные, когнитивные и поведенческие реакции, позволяющие справляться с имеющимися последствиями психических травм и будущими стрессами. Важной составляющей в преодолении пережитых психотравмирующих событий являются убеждения и верования пациентов. Психотерапия и психологическая коррекция способствуют переосмыслению произошедшего и формирует основу для адаптивного разрешения психотравмирующей ситуации.
|
||||
6. Принцип динамического сопровождения:
|
||||
Очень часто пациенты с ПТСР прекращают посещение врача и/или прием медикаментов после некоторого улучшения состояния, что влечет за собой обострение психопатологической симптоматики и ухудшение состояния. Как следствие этого у пациентов появляется разочарование в проводимом лечении и снижается доверие к лечащему врачу. При организации психотерапевтической помощи на уровне первичного медицинского звена появляется возможность наблюдения за состоянием пациента в период посещения им поликлиники или во время патронажного медсестринского обхода.
|
||||
Очень часто пациенты с ПТСР прекращают посещение врача и/или прием медикаментов после некоторого улучшения состояния, что влечет за собой обострение психопатологической симптоматики и ухудшение состояния. Как следствие этого у пациентов появляется разочарование в проводимом лечении и снижается доверие к лечащему врачу. При организации психотерапевтической помощи на уровне первичного ⟦стр. 39⟧ медицинского звена появляется возможность наблюдения за состоянием пациента в период посещения им поликлиники или во время патронажного медсестринского обхода.
|
||||
|
||||
--- стр. 39 ---
|
||||
|
||||
@@ -847,7 +847,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- появление особой ранимости и сенситивности, повышенной чувствительности к несправедливости, легкость возникновения чувства вины перед боевыми товарищами;
|
||||
- низкая восприимчивость к малозначимым раздражителям или обстоятельствам, требующим дополнительных психических затрат, в том числе регресс высших личностных свойств со снижением восприимчивости к чужим страданиям и смерти, уходом от решения нравственных проблем, облегчающих господство групповых ценностей;
|
||||
- ослабление интрапсихической переработки впечатлений, снижение способности к вербализации впечатлений и переживаний.
|
||||
Подобные эмоциональные изменения, особенности переживания значимых событий, коммуникации и системы ценностных образований формируются на фоне длительного воздействия боевых стресс-факторов и становятся своего рода приспособлением к реалиям жизни на войне. Наряду с общими психологическими характеристиками в каждом конкретном случае имеются и свои нюансы, что зависит от изначально присущих особенностей личности и конкретного содержания психотравмирующих обстоятельств.
|
||||
Подобные эмоциональные изменения, особенности переживания значимых событий, коммуникации и системы ценностных образований формируются на фоне длительного ⟦стр. 43⟧ воздействия боевых стресс-факторов и становятся своего рода приспособлением к реалиям жизни на войне. Наряду с общими психологическими характеристиками в каждом конкретном случае имеются и свои нюансы, что зависит от изначально присущих особенностей личности и конкретного содержания психотравмирующих обстоятельств.
|
||||
|
||||
--- стр. 43 ---
|
||||
|
||||
@@ -882,7 +882,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- Незамедлительность (Immediate) – начало ее сразу, как только у солдата снижается боеспособность, а не после наступления общей дезадаптации;
|
||||
- Централизованность (Central) – осуществление помощи в определенном месте, отдельно от терапевтических и хирургических пациентов или Контактность (Contact) – координацию контактов между солдатом и его подразделением и командирами с целью ориентации обеих сторон на коммуникацию, осознание и проявление взаимной необходимости с целью быстрой реинтеграции;
|
||||
- Выжидание (Expectant) – намерение при первой возможности возвратить солдата в строй,
|
||||
- Проксимальность (Proximal) – проведение коррекции максимально ближе к подразделению, в котором служит пострадавший,
|
||||
- Проксимальность (Proximal) – проведение коррекции максимально ближе к ⟦стр. 45⟧ подразделению, в котором служит пострадавший,
|
||||
|
||||
--- стр. 45 ---
|
||||
|
||||
@@ -895,7 +895,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
Комментарии: Условием эффективности оказания психиатрической помощи военнослужащим, подвергшимся воздействию боевого стресса, является осуществление в войсковом звене медицинской сортировки по уровневому принципу (предболезненный, пограничный, психотический) и по предполагаемой длительности дезадаптации с выделением трех групп пострадавших [35, 274, 275]:
|
||||
- 1-я группа (кратковременно выбывшие из строя) – эти военнослужащие переносят предболезненные, чаще всего синдромально незавершенные нарушения адаптации или реакции на стресс, которые при своевременной поддержке имеют транзиторный, очень кратковременный характер. Помощь этой категории пострадавших военнослужащих следует оказывать на 1-м этапе психиатрической помощи – в непосредственной близости от своих воинских подразделений на основе принципов BICEPS. Подлежат возврату в свои подразделения в срок до 1 суток.
|
||||
- 2-я группа (уже представляет собой санитарные потери) – это пострадавшие с кратковременными стрессовыми реакциями, нуждающиеся в лечении до 5-7 суток и получающие помощь на 2-м этапе психиатрической помощи – силами Групп психического здоровья (ГПЗ), приданных лечебным учреждениям, дислоцированным в зоне боевых действий (например, Медицинский отряд специального назначения, МОСН). Помощь им оказывается также на основе принципов BICEPS.
|
||||
- 3-я группа (санитарные потери) – это военнослужащие с затяжными и тяжелыми психическими расстройствами, нуждающиеся в более длительном стационарном лечении и эвакуации на последующие этапы – в психиатрические отделения военных госпиталей или, в крайнем случае и при большей доступности, в ближайшие гражданские психиатрические стационары.
|
||||
- 3-я группа (санитарные потери) – это военнослужащие с затяжными и тяжелыми психическими расстройствами, нуждающиеся в более длительном стационарном лечении и ⟦стр. 46⟧ эвакуации на последующие этапы – в психиатрические отделения военных госпиталей или, в крайнем случае и при большей доступности, в ближайшие гражданские психиатрические стационары.
|
||||
|
||||
--- стр. 46 ---
|
||||
|
||||
@@ -904,7 +904,7 @@ Scale – TOP-8) (Приложение Г11) [49, 50].
|
||||
- Лечение военнослужащих, подвергшихся воздействию боевого стресса, на 1-м и 2-м этапам медицинской эвакуации (в войсковом звене медицинской помощи) с целью вторичной профилактики ПТСР рекомендовано проводить с акцентом на предоставление безопасности, отдыха, сна, питания, с минимальным использованием лекарственных средств и ограниченным применением краткосрочных методов психотерапии [35, 274, 275].
|
||||
Уровень убедительности рекомендаций C (уровень достоверности доказательств 5).
|
||||
Комментарии: на 1-м этапе сортировки следует насколько можно избегать установки диагноза, так как здесь нет специалистов-психиатров, нет времени на дифференциальную диагностику, нужно избежать стигматизации и идентификации пострадавшего с ролью пациента. На 1-м этапе лечение должно быть простым и кратковременным. Купируется возбуждение. Обязательно обеспечить сон, так как его восстановление оказывается необходимым условием обратного развития многих реакций не стресс и почти всегда – реакций предболезненного уровня. Задачей лекарственной терапии является обеспечение эвакуации пациентов с тяжелыми психическими расстройствами. Психотерапия возможна, если потери невелики и число пострадавших, поступающих на этапы эвакуации, очень небольшое. Важной составляющей помощи является выявление признаков черепно-мозговой травмы и мониторинг состояния контуженных. Появление анизокории, признаков даже легкой спутанности или любого ухудшения физического или психического состояния контуженного требует немедленной эвакуации, так как при отсутствии лечения быстро может наступить угнетение сознания и остановка дыхания. Решение об эвакуации на 2-й этап психиатрической помощи принимается, только если спустя сутки или двое у военнослужащего сохраняются выраженные симптомы реакции на стресс, расстройства адаптации или имеются признаки контузии и, самое главное, явно нарушена боеспособность.
|
||||
2-й этап помощи может быть представлен медицинскими подразделениями, например, медицинским отрядам специального назначения, которым должны быть приданы группы медико-психологического сопровождения (в составе врача-психиатра и медицинского психолога), и где может быть проведена диагностика психического расстройства и оказан весь объем необходимой помощи. Однако надо ясно определиться, что при отсутствии специалистов психиатров МОСН не может выполнять функции 2-го этапа медицинской помощи пострадавшим с психическими расстройствами. На практике часто бывает так, что функции 1-го и 2-го этапов психиатрической помощи смешиваются, но это ухудшает исходы реакций боевого стресса и повышает риск развития ПТСР. Выбор применяемых на II этапе методов коррекции определяется необходимостью быстрого лечения большого числа пострадавших. Психотерапия, если есть возможность ее применять, должна быть очень кратковременная, отличаться простотой, а ее эффективность – в наименьшей степени зависеть от искусства конкретного специалиста. При выборе средств фармакологической коррекции предпочтение по-прежнему должно отдаваться препаратам производным бензодиазепина. Последнее время в качестве средства предупреждения ПТСР предлагается
|
||||
2-й этап помощи может быть представлен медицинскими подразделениями, например, медицинским отрядам специального назначения, которым должны быть приданы группы медико-психологического сопровождения (в составе врача-психиатра и медицинского психолога), и где может быть проведена диагностика психического расстройства и оказан весь объем необходимой помощи. Однако надо ясно определиться, что при отсутствии специалистов психиатров МОСН не может выполнять функции 2-го этапа медицинской помощи пострадавшим с психическими расстройствами. На практике часто бывает так, что функции 1-го и 2-го этапов психиатрической помощи смешиваются, но это ухудшает исходы реакций боевого стресса и повышает риск развития ПТСР. Выбор применяемых на II этапе методов коррекции определяется необходимостью быстрого лечения большого числа ⟦стр. 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 ---
|
||||
|
||||
@@ -984,7 +984,7 @@ N
|
||||
Список литературы
|
||||
1. ПСИХОТЕРАПИЯ: УЧЕБНИК/под ред. А.В. Васильевой, Т.А. Караваевой, Н.Г.
|
||||
Незнанова. – Москва: ГЭОТАР-Медиа, 2022. – 864 с.: ил. – DOI: 10.33029/9704-6485-4-VKN2022-1-86
|
||||
2. МКБ. Международная классификация болезней, 10-й пересмотр. Психические расстройства и расстройства поведения. Класс V МКБ-10, адаптированный для использования в Российской Федерации, М.: Российское общество психиатров, 1998.
|
||||
2. МКБ. Международная классификация болезней, 10-й пересмотр. Психические расстройства и расстройства поведения. Класс V МКБ-10, адаптированный для ⟦стр. 50⟧ использования в Российской Федерации, М.: Российское общество психиатров, 1998.
|
||||
|
||||
--- стр. 50 ---
|
||||
|
||||
@@ -1070,7 +1070,7 @@ Nov; 56(4). P. 340-350.
|
||||
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
|
||||
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 ---
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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 ---
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
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 ---
|
||||
|
||||
@@ -1227,7 +1227,7 @@ PTSD: A double-blind, placebo-controlled study. Am J Psychiatry 2002; 159(10): 1
|
||||
Психиатрия и психофармакотерапия. 2016; 18 (2): 26-32
|
||||
108. Беккер Р.А., Быков Ю.В. Клиническое применение перициазина (Неулептила).
|
||||
Психиатрия и психофармакотерапия. 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 ---
|
||||
|
||||
@@ -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.
|
||||
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).
|
||||
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 ---
|
||||
|
||||
@@ -1509,7 +1509,7 @@ Vol. 99, N 26. P. e20700.
|
||||
Posttraumatic Stress Disorder: A Systematic Review and Meta-Analysis//J Trauma Dissociation.
|
||||
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.
|
||||
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 ---
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
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 ---
|
||||
|
||||
@@ -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.
|
||||
8, 2010, doi: 10.1016/j.janxdis.2010.06.016.
|
||||
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 ---
|
||||
|
||||
@@ -1647,7 +1647,7 @@ Department of the Army. July, 2006. 115 p.
|
||||
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.
|
||||
278. Руководство для командиров по контролю над боевым стрессом (Полевой Устав
|
||||
Сухопутных войск США FM 22-51): сокр. пер. с англ. А.П. Нечаева//Зарубежная военная медицина (Информационный сборник). – N 96. – СПб, 1999. – С. 8-84.
|
||||
Сухопутных войск США FM 22-51): сокр. пер. с англ. А.П. Нечаева//Зарубежная военная ⟦стр. 67⟧ медицина (Информационный сборник). – N 96. – СПб, 1999. – С. 8-84.
|
||||
|
||||
--- стр. 67 ---
|
||||
|
||||
@@ -1915,7 +1915,7 @@ German-speaking countries. European Archives of Psychiatry and Clinical Neurosci
|
||||
Информация для пациента
|
||||
Что такое посттравматическое стрессовое расстройство (ПТСР)?
|
||||
ПТСР – это расстройство, которое может развиться после перенесенного очень сильного стресса, который является тяжелым практически для любого человека и превышает нормальные возможности психики к адаптации.
|
||||
К таким психотравмирующим воздействиям относят угрозу жизни, нападение, пытки, насилие, природные и техногенные катастрофы, военные действия, террористические акты, пребывание в плену или концентрационном лагере, свидетельство гибели другого человека, жизнеугрожающее заболевание, получение известия о неожиданной или насильственной смерти близкого человека и другие.
|
||||
К таким психотравмирующим воздействиям относят угрозу жизни, нападение, пытки, насилие, природные и техногенные катастрофы, военные действия, террористические акты, пребывание в плену или концентрационном лагере, свидетельство гибели другого человека, ⟦стр. 77⟧ жизнеугрожающее заболевание, получение известия о неожиданной или насильственной смерти близкого человека и другие.
|
||||
|
||||
--- стр. 77 ---
|
||||
|
||||
@@ -2016,7 +2016,7 @@ Scale – CAPS) разработана в двух вариантах (Weathers
|
||||
Применение методики не только дает возможность оценить по пятибалльной шкале каждый симптом с точки зрения частоты и интенсивности проявления, но и определить достоверность полученной информации.
|
||||
Шкала CAPS применяется, как правило, дополнительно к Структурированному клиническому интервью (СКИД) для клинической диагностики уровня выраженности симптоматики ПТСР и частоты ее проявления. Ее используют, если в ходе интервью диагностируется наличие каких-либо симптомов ПТСР или всего расстройства в целом
|
||||
(Weathers F.W., Litz B.T., 1994; Blake D.D., 1995).
|
||||
CAPS-1 позволяет оценить частоту встречаемости и интенсивность проявления индивидуальных симптомов расстройства, а также степень их влияния на социальную активность и производственную деятельность пациента. С помощью этой шкалы можно определить степень улучшения состояния при повторном исследовании по сравнению с предыдущим, валидность результатов и общую интенсивность симптомов. Если возможно, желательно использовать шкалу CAPS-1 в комплексе с прочими методами диагностики
|
||||
CAPS-1 позволяет оценить частоту встречаемости и интенсивность проявления индивидуальных симптомов расстройства, а также степень их влияния на социальную активность и производственную деятельность пациента. С помощью этой шкалы можно определить степень улучшения состояния при повторном исследовании по сравнению с предыдущим, валидность результатов и общую интенсивность симптомов. Если возможно, ⟦стр. 81⟧ желательно использовать шкалу CAPS-1 в комплексе с прочими методами диагностики
|
||||
|
||||
--- стр. 81 ---
|
||||
|
||||
@@ -2401,7 +2401,7 @@ Kudler, 1989);
|
||||
| ОТМЕЧЕНО ЛИ 15 КАК ДА? | | НЕТ ДА ПТСР в настоящее время | | |
|
||||
|
||||
Ключ (интерпретация)
|
||||
Наличие утвердительных ответов на вопросы, направленные на диагностический критерий A, дают интервьюеру основание для дальнейшей беседы по следующим блокам вопросов, направленных на диагностические критерии B – травматическое событие настойчиво повторяется в переживании, C – постоянное избегание стимулов и блокировка эмоциональных реакций, связанных с травмой, D – постоянные симптомы возрастающего возбуждения, E – длительность протекания расстройства (более чем 1 месяц), F – расстройство вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности.
|
||||
Наличие утвердительных ответов на вопросы, направленные на диагностический ⟦стр. 93⟧ критерий A, дают интервьюеру основание для дальнейшей беседы по следующим блокам вопросов, направленных на диагностические критерии B – травматическое событие настойчиво повторяется в переживании, C – постоянное избегание стимулов и блокировка эмоциональных реакций, связанных с травмой, D – постоянные симптомы возрастающего возбуждения, E – длительность протекания расстройства (более чем 1 месяц), F – расстройство вызывает клинически значимое тяжелое эмоциональное состояние или нарушения в социальной, профессиональной или других важных сферах жизнедеятельности.
|
||||
|
||||
--- стр. 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,
|
||||
15, 18, 19, 21; – позволяет выявлять у испытуемого злость и раздражительность, гипертрофированную реакцию испуга, психофизиологическое возбуждение, обусловленное воспоминаниями о событии, бессонницу.
|
||||
Все пункты имеют прямое значение.
|
||||
Опросник предназначен для изучения наличия, степени выраженности и симптоматической структуры посттравматического стрессового расстройства. Шкала зарекомендовала себя как надежный, валидный инструмент, прошедший кросс-культурную апробацию. Шкала может быть включена в батарею психодиагностических методик для работы с лицами, у которых имеется или подозревается ПТСР.
|
||||
Опросник предназначен для изучения наличия, степени выраженности и симптоматической структуры посттравматического стрессового расстройства. Шкала ⟦стр. 94⟧ зарекомендовала себя как надежный, валидный инструмент, прошедший кросс-культурную апробацию. Шкала может быть включена в батарею психодиагностических методик для работы с лицами, у которых имеется или подозревается ПТСР.
|
||||
|
||||
--- стр. 94 ---
|
||||
|
||||
@@ -2487,7 +2487,7 @@ Mississippi Scale) – клиническая тестовая методика,
|
||||
Опросник состоит из 35 вопросов в "военном" и 39 вопросов в "гражданском" вариантах. 30 вопросов образуют три основные шкалы, которые соотносятся с тремя группами симптомов ПТСР согласно DSM-III: 11 вопросов первой шкалы описывают симптомы группы вторжения, 11 симптомов второй шкалы описывают симптомы группы избегания, 8 вопросов третьей шкалы описывают симптомы возбудимости. Пять оставшихся вопросов описывают симптомы, относящиеся к чувству вины и суицидальным наклонностям.
|
||||
Популяционные исследования показали высокую внутреннюю согласованность военного (0,94) и гражданского (0,86) вариантов шкалы. Шкала обладает
|
||||
87% чувствительностью и 63% специфичностью в диагностике ПТСР у контингента психически больных.
|
||||
Ответы на вопросы даются по шкале Ликкерта. Несмотря на группирование вопросов в четыре субшкалы, высчитывается только один итоговый коэффициент. Часть вопросов имеет перевернутые значения, что позволяет снизить установочность испытуемого или заполнение бланка в произвольном порядке.
|
||||
Ответы на вопросы даются по шкале Ликкерта. Несмотря на группирование вопросов в четыре субшкалы, высчитывается только один итоговый коэффициент. Часть вопросов имеет ⟦стр. 96⟧ перевернутые значения, что позволяет снизить установочность испытуемого или заполнение бланка в произвольном порядке.
|
||||
|
||||
--- стр. 96 ---
|
||||
|
||||
@@ -2561,7 +2561,7 @@ Mississippi Scale) – клиническая тестовая методика,
|
||||
Семейное положение _____________ Детей ____________________
|
||||
Профессия ____________________________________________________
|
||||
Вес ____________ Рост ____________
|
||||
Ниже приводятся утверждения, обобщающие различный опыт людей, принимавших участие в боевых действиях. У каждого утверждения есть шкала от "1" до "5". Пользуясь данной шкалой, укажите, в какой степени вы согласны или не согласны с данным утверждением. Дайте только один ответ на каждое утверждение: Совершенно неверно,
|
||||
Ниже приводятся утверждения, обобщающие различный опыт людей, принимавших участие в боевых действиях. У каждого утверждения есть шкала от "1" до "5". Пользуясь данной шкалой, укажите, в какой степени вы согласны или не согласны с данным ⟦стр. 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 ---
|
||||
|
||||
@@ -2770,7 +2770,7 @@ Weathers F.W, 1993), "Миссисипская шкала для оценки п
|
||||
L и др., 1990)], вопросы которой касались переживаний субъекта в то время, когда с ним происходило потенциально психотравмирующее событие.
|
||||
Обследуемым предлагается ответить на 9 вопросов, в которых описаны различные диссоциативные состояния, такие, например, как дереализация, деперсонализация, опыт выхода из тела и др. Ответы на вопросы даются в формате пятибалльной шкалы.
|
||||
Общий балл подсчитывается как среднее арифметическое по 9 пунктам.
|
||||
Диагностические критерии следующие: у здоровых показатели от 9 до 15 баллов; у пациентов с посттравматическими стрессовыми расстройствами – от 19 до 45 баллов.
|
||||
Диагностические критерии следующие: у здоровых показатели от 9 до 15 баллов; у ⟦стр. 106⟧ пациентов с посттравматическими стрессовыми расстройствами – от 19 до 45 баллов.
|
||||
|
||||
--- стр. 106 ---
|
||||
|
||||
@@ -2900,7 +2900,7 @@ F.W., 1986)
|
||||
| 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
|
||||
12) ... испытывают чувство нереальности окружающих их людей и предметов, а может быть, и всего окружающего их мира.
|
||||
12) ... испытывают чувство нереальности окружающих их людей и предметов, а может ⟦стр. 110⟧ быть, и всего окружающего их мира.
|
||||
|
||||
--- стр. 110 ---
|
||||
|
||||
@@ -2947,7 +2947,7 @@ F.W., 1986)
|
||||
| 0% | 10% | 20% | 30% | 40% | 50% | 60% | 70% | 80% | 90% | 100% |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
|
||||
21) ... находясь в одиночестве, иногда замечают, что громко разговаривают сами с собой.
|
||||
21) ... находясь в одиночестве, иногда замечают, что громко разговаривают сами с ⟦стр. 111⟧ собой.
|
||||
|
||||
--- стр. 111 ---
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ retrieval:
|
||||
"Приложение В": 1.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:
|
||||
max_chunks: 5 # в скольких лучших фрагментах ищем отрывок
|
||||
|
||||
@@ -63,6 +63,7 @@ idle:
|
||||
buttons:
|
||||
checkin: "Как я сегодня"
|
||||
med_question: "Вопрос о лекарстве"
|
||||
kb_question: "Вопрос о лечении"
|
||||
contact_doctor: "Связаться с врачом"
|
||||
revoke: "Отозвать согласие"
|
||||
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
|
||||
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
|
||||
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
|
||||
|
||||
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
|
||||
prompt: |-
|
||||
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
|
||||
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
|
||||
|
||||
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
|
||||
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
|
||||
|
||||
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
|
||||
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
|
||||
question_starts:
|
||||
- "что такое"
|
||||
- "что значит"
|
||||
- "что означает"
|
||||
- "почему"
|
||||
- "зачем"
|
||||
- "как"
|
||||
- "когда"
|
||||
- "сколько"
|
||||
- "можно ли"
|
||||
- "нужно ли"
|
||||
- "надо ли"
|
||||
- "помогает ли"
|
||||
- "поможет ли"
|
||||
- "правда ли"
|
||||
- "бывает ли"
|
||||
- "что делать"
|
||||
- "что будет"
|
||||
- "чем"
|
||||
- "где"
|
||||
- "есть ли"
|
||||
|
||||
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
|
||||
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
|
||||
small_talk:
|
||||
- "как дела"
|
||||
- "как ты"
|
||||
- "как вы"
|
||||
- "как жизнь"
|
||||
- "как поживаете"
|
||||
- "что нового"
|
||||
- "кто ты"
|
||||
- "ты кто"
|
||||
- "ты бот"
|
||||
- "ты робот"
|
||||
- "привет"
|
||||
- "спасибо"
|
||||
@@ -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
@@ -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
|
||||
import streamlit as st
|
||||
* file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
|
||||
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")
|
||||
st.title("Кабинет врача")
|
||||
st.caption("Прототип на синтетических данных")
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
|
||||
except httpx.HTTPError:
|
||||
status = "недоступен"
|
||||
st.write(f"API: **{status}**")
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_HERE = str(Path(__file__).resolve().parent)
|
||||
# `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
|
||||
# (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
|
||||
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()
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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}; используется токен из окружения"
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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:]))
|
||||
@@ -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
|
||||
@@ -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 (("&", "&"), ("<", "<"), (">", ">")):
|
||||
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
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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: "желатин"
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"}]}
|
||||
@@ -9,7 +9,8 @@
|
||||
- таблицы -> markdown; у таблицы-продолжения на новой странице повторяется шапка,
|
||||
строка, разорванная границей страницы, склеивается;
|
||||
- колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются;
|
||||
- маркеры страниц `--- стр. N ---` для ссылок на источник;
|
||||
- маркеры страниц `--- стр. N ---` для ссылок на источник; абзац, перетёкший на новую
|
||||
страницу, склеивается, а место перехода помечается `⟦стр. N⟧`;
|
||||
- текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p<N>.txt
|
||||
(ручная транскрипция) и вставляется в конец своей страницы;
|
||||
- если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом
|
||||
@@ -211,9 +212,10 @@ def convert(pdf_path: Path) -> str:
|
||||
if not text:
|
||||
continue
|
||||
if first_on_page and prev and prev[0] == "text" and text[0].islower():
|
||||
# абзац перетёк на новую страницу: дописываем его хвост к началу
|
||||
# абзац перетёк на новую страницу: дописываем его хвост к началу, а место
|
||||
# перехода помечаем ⟦стр. N⟧ — чанкер по метке ставит верную страницу в цитату
|
||||
head, _, rest = text.partition("\n")
|
||||
prev[1] = prev[1] + " " + head
|
||||
prev[1] = prev[1] + f" ⟦стр. {num}⟧ " + head
|
||||
if rest:
|
||||
blocks.append(["text", rest])
|
||||
else:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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("") == ""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
|
||||
|
||||
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()
|
||||
second = (await client.post("/api/v1/auth/anonymous")).json()
|
||||
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:
|
||||
assert await identity_repo.get_patient(s, first["token"]) is None
|
||||
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:
|
||||
@@ -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())
|
||||
|
||||
|
||||
async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules.
|
||||
async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Stage 1 is the same for everyone: it does not wait for a module (it sets them itself).
|
||||
monkeypatch.setenv("INTAKE_DEMO", "1")
|
||||
token = "c" * 32
|
||||
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
|
||||
|
||||
async def press(button: str | None = None) -> list[str]:
|
||||
event = {"type": "button", "button": button} if button else {"type": "start"}
|
||||
@@ -213,3 +215,8 @@ async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypat
|
||||
await press()
|
||||
await press("consent:yes")
|
||||
assert any(":phq9_1:" in b for b in await press("age:adult"))
|
||||
|
||||
|
||||
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
|
||||
body = (await client.get("/api/v1/patient/demo-personas")).json()
|
||||
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
|
||||
|
||||
@@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None:
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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 "<script>" 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 "<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] == ["Пациенты", "Подключить пациента"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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("Пациент #—")
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
import pytest
|
||||
|
||||
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.gateway import LLMGateway, is_forbidden
|
||||
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
|
||||
|
||||
|
||||
async def test_default_registry_has_no_data(mock_gateway: None) -> None:
|
||||
# Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing.
|
||||
async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -307,3 +307,38 @@ async def test_llm_rephrasing_kept_when_faithful(monkeypatch: pytest.MonkeyPatch
|
||||
reply = await answer(S(q), audience="patient")
|
||||
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
|
||||
|
||||
|
||||
# --- 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"
|
||||
|
||||
@@ -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
|
||||
@@ -44,7 +44,7 @@ export const api = {
|
||||
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
|
||||
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
|
||||
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
|
||||
demoPersonas: () => request<DemoPersona[]>("/demo/personas"),
|
||||
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
|
||||
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
|
||||
catalog: () => request<Catalog>("/instruments/catalog"),
|
||||
medsReference: () => request<MedsReference>("/meds/reference"),
|
||||
|
||||
Reference in New Issue
Block a user