Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0106aefc33 | ||
|
|
290b0d6151 | ||
|
|
6e7c89900b | ||
|
|
37f119610d | ||
|
|
05b8bc3e7e | ||
|
|
aac560ad71 | ||
|
|
473965ae76 | ||
|
|
9ec61b9a03 | ||
|
|
c01bfc90df | ||
|
|
a2b1b2e7a5 | ||
|
|
6dda5f9b30 | ||
|
|
2f1f8a8f24 | ||
|
|
497fb551f5 | ||
|
|
8e62722ec0 | ||
|
|
2582c73d12 | ||
|
|
8d12aa3d08 | ||
|
|
bc91873a13 | ||
|
|
260422138a | ||
|
|
178cab5b1b | ||
|
|
c4eab1ef66 | ||
|
|
9362eb6ecc | ||
|
|
25728c4b31 | ||
|
|
53818ac76c | ||
|
|
b72af71e66 | ||
|
|
7272203178 | ||
|
|
0195468edc | ||
|
|
464b474abd | ||
|
|
40c242bb1a | ||
|
|
f233c1e5ca | ||
|
|
b4b1bc51c8 | ||
|
|
b8a93ce9d4 | ||
|
|
4d331e4c05 | ||
|
|
fe29ebd629 | ||
|
|
6c212d560c | ||
|
|
6a77831603 | ||
|
|
66986255e2 | ||
|
|
251f946691 | ||
|
|
8c620419dd | ||
|
|
4c0836a1be | ||
|
|
20973bef17 | ||
|
|
d06720fdab | ||
|
|
2b7cbb8c2f | ||
|
|
2972b330b0 | ||
|
|
2b13d54ed7 | ||
|
|
4c6e150162 | ||
|
|
de5b1e05fb | ||
|
|
0a92266bab |
@@ -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=
|
||||
|
||||
@@ -22,6 +27,9 @@ DOCTOR_NOTIFY_CHAT_ID=
|
||||
|
||||
# --- Demo virtual clock (ADR-005)
|
||||
DEMO_CLOCK=true
|
||||
# Questionnaires of the intake (#53) on synthetic data, demo only (needs DEMO_CLOCK). The clinical release gate in
|
||||
# config/intake_runtime.yaml stays closed; without a signed routing policy every finished intake goes to review.
|
||||
INTAKE_DEMO=false
|
||||
TZ=Europe/Moscow
|
||||
|
||||
# --- Gitea (for Claude Code / team workflow, not used by the app)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Shell scripts run inside Linux containers: never check them out with CRLF (bash fails on the carriage return).
|
||||
*.sh text eol=lf
|
||||
@@ -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),
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from app.api.schemas import (
|
||||
AlertOut,
|
||||
DemoPersona,
|
||||
DiscontinuationRisk,
|
||||
DoseOut,
|
||||
PatientCard,
|
||||
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
|
||||
TODAY = date(2026, 10, 3)
|
||||
DOCTOR_ID = "doc-smirnova"
|
||||
|
||||
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
|
||||
DEMO_PERSONAS: list[DemoPersona] = [
|
||||
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
|
||||
]
|
||||
|
||||
|
||||
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
|
||||
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
|
||||
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
|
||||
|
||||
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
|
||||
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
|
||||
|
||||
Two ways in:
|
||||
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
|
||||
patient is linked to the attending doctor, who sees the data on the dashboard.
|
||||
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
|
||||
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
|
||||
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
|
||||
|
||||
The token returned here is the session credential of the prototype. Production needs short-lived sessions
|
||||
issued by the server instead of handing out the pseudonymous token itself.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import DemoPersona
|
||||
from app.core import clock
|
||||
from app.core.instrument_catalog import Catalog, catalog
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import intake_enabled, load_runtime
|
||||
from app.core.interpretation import texts as results_texts
|
||||
from app.core.meds_reference import find_drug, indication_for, load_reference
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||||
from app.db.identity import TOKEN_LEN
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.db.session import get_clinical_session
|
||||
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["patient-web"])
|
||||
_emias = EmiasMock()
|
||||
|
||||
|
||||
class AuthOut(BaseModel):
|
||||
token: str
|
||||
mode: Literal["emias", "anonymous"]
|
||||
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
|
||||
|
||||
|
||||
class EmiasCodeIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
|
||||
|
||||
|
||||
class AnonymousIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
|
||||
|
||||
|
||||
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
|
||||
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
|
||||
|
||||
|
||||
class EmiasDemoIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
persona_token: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
async def _attach_doctor(token: str, doctor_id: str) -> None:
|
||||
async with get_clinical_session() as s:
|
||||
if await repo.get_profile(s, token) is None:
|
||||
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
||||
|
||||
|
||||
async def _seed_demo_persona(token: str) -> None:
|
||||
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
|
||||
|
||||
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
|
||||
doctor's card tell the same story.
|
||||
"""
|
||||
card = fixtures.card(token)
|
||||
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
|
||||
if card is None or item is None:
|
||||
return
|
||||
now = clock.now()
|
||||
today = now.date()
|
||||
async with get_clinical_session() as s:
|
||||
if await repo.get_profile(s, token) is None:
|
||||
await repo.upsert_profile(
|
||||
s,
|
||||
token=token,
|
||||
doctor_id=fixtures.DOCTOR_ID,
|
||||
modules=list(item.modules),
|
||||
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
|
||||
next_visit=today + timedelta(days=15),
|
||||
)
|
||||
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
|
||||
return
|
||||
for p in card.prescriptions:
|
||||
rx = Prescription(
|
||||
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
|
||||
)
|
||||
s.add(rx)
|
||||
await s.flush()
|
||||
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
|
||||
for d in card.doses:
|
||||
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
|
||||
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
|
||||
for t in p.schedule.get("times", []):
|
||||
hh, mm = (int(x) for x in t.split(":"))
|
||||
s.add(
|
||||
Dose(
|
||||
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/emias", response_model=AuthOut)
|
||||
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||||
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
||||
person = await _emias.verify(body.code)
|
||||
if person is None:
|
||||
log.info("web auth: emias code rejected")
|
||||
raise HTTPException(401, "code not accepted")
|
||||
await _attach_doctor(person.token, person.doctor_id)
|
||||
log.info("web auth: emias tok=%s", person.token[:6])
|
||||
return AuthOut(token=person.token, mode="emias")
|
||||
|
||||
|
||||
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
|
||||
async def patient_demo_personas() -> list[DemoPersona]:
|
||||
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
|
||||
return fixtures.DEMO_PERSONAS
|
||||
|
||||
|
||||
@router.post("/auth/emias/demo", response_model=AuthOut)
|
||||
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`)."""
|
||||
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
||||
if persona is None:
|
||||
raise HTTPException(404, "persona not found")
|
||||
await _seed_demo_persona(persona.token)
|
||||
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
||||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
||||
|
||||
|
||||
@router.post("/auth/anonymous", response_model=AuthOut)
|
||||
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
|
||||
|
||||
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
|
||||
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
|
||||
"""
|
||||
token = secrets.token_hex(TOKEN_LEN // 2)
|
||||
focus = body.focus if body is not None else None
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else [])
|
||||
log.info("web auth: anonymous tok=%s", token[:6])
|
||||
return AuthOut(token=token, mode="anonymous")
|
||||
|
||||
|
||||
class DoseItem(BaseModel):
|
||||
id: int
|
||||
drug: str
|
||||
dose: str
|
||||
time: str # HH:MM, patient's local time
|
||||
status: Literal["taken", "missed", "skipped", "pending"]
|
||||
|
||||
|
||||
class WeekDay(BaseModel):
|
||||
day: date
|
||||
status: Literal["taken", "missed", "pending", "none"]
|
||||
|
||||
|
||||
class PrescriptionItem(BaseModel):
|
||||
drug: str
|
||||
dose: str
|
||||
times: list[str]
|
||||
week: list[WeekDay] # Mon..Sun of the current week
|
||||
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
|
||||
indication: Literal["depression", "ptsd"] | None = None
|
||||
start_date: date | None = None
|
||||
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
|
||||
|
||||
|
||||
class Questionnaires(BaseModel):
|
||||
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
|
||||
|
||||
enabled: bool
|
||||
intake: Literal["none", "active", "paused", "complete", "review"]
|
||||
diary_done: bool # both parts of today's diary
|
||||
diary_next: Literal["morning", "evening"] | None = None # the part still open today
|
||||
|
||||
|
||||
class TodayOut(BaseModel):
|
||||
today: date
|
||||
now: str # HH:MM on the (demo) clock — the client never uses its own clock
|
||||
has_doctor: bool
|
||||
checkin_done: bool
|
||||
next_visit: date | None
|
||||
doses: list[DoseItem]
|
||||
prescriptions: list[PrescriptionItem]
|
||||
questionnaires: Questionnaires
|
||||
|
||||
|
||||
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
|
||||
intake = [r for r in records if r.kind == "intake"]
|
||||
if any(r.status == "complete" for r in intake):
|
||||
return "complete"
|
||||
if not intake:
|
||||
return "none"
|
||||
last = max(intake, key=lambda r: r.started_at).status
|
||||
if last in ("active", "paused"):
|
||||
return last
|
||||
return "review" if last in ("review_required", "interrupted") else "none"
|
||||
|
||||
|
||||
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
|
||||
if not statuses:
|
||||
return "none"
|
||||
if any(s in ("missed", "skipped") for s in statuses):
|
||||
return "missed"
|
||||
return "taken" if all(s == "taken" for s in statuses) else "pending"
|
||||
|
||||
|
||||
@router.get("/me/{token}/today", response_model=TodayOut)
|
||||
async def my_today(token: str) -> TodayOut:
|
||||
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
|
||||
|
||||
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
|
||||
"""
|
||||
now = clock.now()
|
||||
today = now.date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
async with get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, token)
|
||||
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
|
||||
doses = (
|
||||
await s.scalars(
|
||||
select(Dose)
|
||||
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
|
||||
.order_by(Dose.planned_at)
|
||||
)
|
||||
).all()
|
||||
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
|
||||
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
|
||||
|
||||
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
|
||||
today_doses: list[DoseItem] = []
|
||||
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
|
||||
for d in doses:
|
||||
local = d.planned_at.astimezone(now.tzinfo)
|
||||
marks[(d.prescription_id, local.date())].append(d.status)
|
||||
rx = by_rx.get(d.prescription_id)
|
||||
if local.date() == today and rx is not None:
|
||||
today_doses.append(
|
||||
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
|
||||
)
|
||||
week = [week_start + timedelta(days=i) for i in range(7)]
|
||||
modules = list(profile.modules or []) if profile else []
|
||||
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
|
||||
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
|
||||
diary_done = not open_parts
|
||||
|
||||
def item(p: Prescription) -> PrescriptionItem:
|
||||
ref = find_drug(p.drug)
|
||||
return PrescriptionItem(
|
||||
drug=p.drug,
|
||||
dose=p.dose,
|
||||
times=list((p.schedule or {}).get("times", [])),
|
||||
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
|
||||
ref_id=ref["id"] if ref else None,
|
||||
indication=indication_for(modules, ref) if ref else None,
|
||||
start_date=p.start_date,
|
||||
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
|
||||
)
|
||||
|
||||
return TodayOut(
|
||||
today=today,
|
||||
now=now.strftime("%H:%M"),
|
||||
has_doctor=profile is not None and bool(profile.doctor_id),
|
||||
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
|
||||
next_visit=profile.next_visit if profile else None,
|
||||
doses=today_doses,
|
||||
prescriptions=[
|
||||
item(p)
|
||||
for p in prescriptions
|
||||
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||||
],
|
||||
questionnaires=Questionnaires(
|
||||
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
|
||||
enabled=intake_enabled(),
|
||||
intake=_intake_state(records),
|
||||
diary_done=diary_done,
|
||||
diary_next=open_parts[0] if open_parts else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ResultFinding(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
|
||||
|
||||
class Conclusion(BaseModel):
|
||||
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
|
||||
|
||||
day: date
|
||||
title: str
|
||||
lines: list[str]
|
||||
findings: list[ResultFinding]
|
||||
modules: list[str]
|
||||
disclaimer: str
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_title: str
|
||||
status: str
|
||||
status_title: str
|
||||
day: date
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
title: str | None # interpretation shown to the patient when the form was finished
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class ResultsOut(BaseModel):
|
||||
conclusion: Conclusion | None
|
||||
history: list[HistoryItem]
|
||||
|
||||
|
||||
@router.get("/me/{token}/results", response_model=ResultsOut)
|
||||
async def my_results(token: str) -> ResultsOut:
|
||||
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
|
||||
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
|
||||
async with get_clinical_session() as s:
|
||||
records = list(
|
||||
(
|
||||
await s.scalars(
|
||||
select(QuestionnaireRecord)
|
||||
.where(QuestionnaireRecord.token == token)
|
||||
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
t = results_texts()
|
||||
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
|
||||
conclusion = None
|
||||
if intake and intake.details.get("interpretation"):
|
||||
i = intake.details["interpretation"]
|
||||
conclusion = Conclusion(
|
||||
day=intake.day,
|
||||
title=i["title"],
|
||||
lines=i["lines"],
|
||||
findings=[ResultFinding(**f) for f in i.get("findings", [])],
|
||||
modules=i.get("modules", []),
|
||||
disclaimer=i.get("disclaimer", t["disclaimer"]),
|
||||
)
|
||||
history = [
|
||||
HistoryItem(
|
||||
id=r.id,
|
||||
kind=r.kind,
|
||||
kind_title=t["kinds"].get(r.kind, r.kind),
|
||||
status=r.status,
|
||||
status_title=t["status"].get(r.status, r.status),
|
||||
day=r.day,
|
||||
started_at=r.started_at,
|
||||
completed_at=r.completed_at,
|
||||
title=(r.details.get("interpretation") or {}).get("title"),
|
||||
lines=(r.details.get("interpretation") or {}).get("lines", []),
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
return ResultsOut(conclusion=conclusion, history=history)
|
||||
|
||||
|
||||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||||
async def crisis_resources() -> list[CrisisResource]:
|
||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||
return crisis_reply([], "patient").resources
|
||||
|
||||
|
||||
@router.get("/instruments/catalog", response_model=Catalog)
|
||||
async def instruments_catalog() -> Catalog:
|
||||
"""Form, position and recall period of every questionnaire item, for the question screen header."""
|
||||
return catalog()
|
||||
|
||||
|
||||
@router.get("/meds/reference")
|
||||
async def meds_reference() -> dict[str, Any]:
|
||||
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
|
||||
|
||||
The internal rule parameters stay on the server.
|
||||
"""
|
||||
ref = load_reference()
|
||||
return {k: v for k, v in ref.items() if k != "rules"}
|
||||
+193
-24
@@ -1,12 +1,22 @@
|
||||
"""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
|
||||
from app.api.schemas import (
|
||||
AckIn,
|
||||
@@ -23,68 +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:
|
||||
"""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)
|
||||
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")
|
||||
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
|
||||
profile = await repo.get_profile(s, alert.token)
|
||||
if profile is None or profile.doctor_id != body.doctor_id:
|
||||
raise HTTPException(403, "not your patient")
|
||||
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
|
||||
await refresh_status(s, alert.token)
|
||||
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
|
||||
out = views.alert_out(alert, "")
|
||||
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
|
||||
if alert.level == "acute" and not open_acute:
|
||||
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
|
||||
if replies:
|
||||
await integrations.outbox.push(alert.token, replies)
|
||||
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
|
||||
|
||||
|
||||
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
|
||||
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)
|
||||
@@ -99,21 +245,44 @@ async def get_clock() -> ClockOut:
|
||||
|
||||
@router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"])
|
||||
async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
|
||||
"""Moves the virtual clock; #13 makes the scheduler replay all events up to the new time."""
|
||||
"""Moves the virtual clock and replays the scheduler up to the new time (#13)."""
|
||||
clock.advance(days=body.days, hours=body.hours)
|
||||
await scheduler.run_until_now()
|
||||
return _clock_out()
|
||||
|
||||
|
||||
@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 [
|
||||
"""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="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Read-only catalog of questionnaire items for patient clients (web app).
|
||||
|
||||
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
|
||||
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
|
||||
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
|
||||
the versioned sources in config/.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
|
||||
from app.core.scales import load_scale
|
||||
|
||||
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
|
||||
|
||||
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
|
||||
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
|
||||
|
||||
|
||||
class FormInfo(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
section: Section
|
||||
recall_period: str | None = None
|
||||
instruction: str | None = None
|
||||
|
||||
|
||||
class ItemInfo(BaseModel):
|
||||
form: str
|
||||
index: int # 1-based position in the form
|
||||
total: int
|
||||
adaptive: bool
|
||||
optional: bool = False
|
||||
numeric: bool = False
|
||||
|
||||
|
||||
class DrinksTable(BaseModel):
|
||||
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
|
||||
|
||||
title: str
|
||||
header: list[str]
|
||||
rows: list[tuple[str, list[str]]]
|
||||
note: str
|
||||
|
||||
|
||||
class Catalog(BaseModel):
|
||||
forms: dict[str, FormInfo]
|
||||
items: dict[str, ItemInfo]
|
||||
standard_drinks: DrinksTable | None
|
||||
|
||||
|
||||
def _drinks() -> DrinksTable | None:
|
||||
raw = texts().get("standard_drinks")
|
||||
if not raw:
|
||||
return None
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
title, header_line, *rest = lines
|
||||
rows = [line for line in rest if ":" in line]
|
||||
note = " ".join(line for line in rest if ":" not in line)
|
||||
return DrinksTable(
|
||||
title=title,
|
||||
header=[h.strip() for h in header_line.split("·")],
|
||||
rows=[
|
||||
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
|
||||
],
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def catalog() -> Catalog:
|
||||
forms: dict[str, FormInfo] = {}
|
||||
items: dict[str, ItemInfo] = {}
|
||||
|
||||
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
|
||||
forms[form.id] = form
|
||||
for i, item in enumerate(ids, 1):
|
||||
items.setdefault(
|
||||
item,
|
||||
ItemInfo(
|
||||
form=form.id,
|
||||
index=i,
|
||||
total=len(ids),
|
||||
adaptive=form.id in ADAPTIVE,
|
||||
optional=item in optional,
|
||||
numeric=item in numeric,
|
||||
),
|
||||
)
|
||||
|
||||
cssrs = load_cssrs_russian()
|
||||
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
|
||||
|
||||
intake_forms: dict[str, Any] = content()["forms"]
|
||||
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
|
||||
raw = intake_forms.get(sid) or {}
|
||||
scale = load_scale(sid)
|
||||
add(
|
||||
FormInfo(
|
||||
id=sid,
|
||||
title=raw.get("title") or scale.title,
|
||||
section=section,
|
||||
recall_period=raw.get("recall_period") or scale.recall_period,
|
||||
instruction=raw.get("instruction") or scale.instruction,
|
||||
),
|
||||
list(scale.item_ids),
|
||||
)
|
||||
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
|
||||
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
|
||||
|
||||
diary = diary_config()
|
||||
core = [item for part in DIARY_PARTS for item in diary[part]]
|
||||
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
|
||||
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
|
||||
add(
|
||||
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
|
||||
[i["id"] for i in core] + depression + ptsd,
|
||||
optional={i["id"] for i in core if i.get("optional")},
|
||||
numeric={i["id"] for i in core if i.get("numeric")},
|
||||
)
|
||||
|
||||
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
|
||||
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
|
||||
scale = load_scale(path.stem)
|
||||
forms.setdefault(
|
||||
scale.id,
|
||||
FormInfo(
|
||||
id=scale.id,
|
||||
title=scale.title,
|
||||
section="scale",
|
||||
recall_period=scale.recall_period,
|
||||
instruction=scale.instruction,
|
||||
),
|
||||
)
|
||||
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
|
||||
@@ -18,6 +18,7 @@ class Question:
|
||||
period: str = "context"
|
||||
numeric: bool = False
|
||||
optional: bool = False
|
||||
maximum: float = 24
|
||||
|
||||
|
||||
def content() -> dict:
|
||||
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
|
||||
|
||||
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
|
||||
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
|
||||
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
|
||||
so a positive answer interrupts the questionnaire wherever it comes."""
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
for item in load_scale("rus_audit_s").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit_s", item)
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
if sex is None and "sex" not in answers:
|
||||
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
|
||||
for key in ("tbi", "psychotropics"):
|
||||
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
return None
|
||||
|
||||
|
||||
def diary_questions(
|
||||
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
|
||||
) -> tuple[Question, ...]:
|
||||
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
|
||||
selected = ["core"]
|
||||
detailed = [name for name in ("veteran", "depression") if name in modules]
|
||||
if len(detailed) == 2:
|
||||
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
|
||||
if "veteran" in detailed:
|
||||
selected.append("ptsd")
|
||||
if "depression" in detailed:
|
||||
selected.append("depression")
|
||||
if suicide_monitoring or "depression" in modules:
|
||||
selected.append("suicide")
|
||||
questions = []
|
||||
for section in selected:
|
||||
for item in config[section]:
|
||||
if item.get("weekly") and weekday != 0:
|
||||
continue
|
||||
questions.append(
|
||||
def cssrs_since_contact_question(answers: dict) -> Question | None:
|
||||
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
|
||||
|
||||
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
|
||||
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
|
||||
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
|
||||
"""
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
missing = [k for k in c.missing if k != "q6_recent"]
|
||||
if not missing:
|
||||
return None
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == missing[0])
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
"since_last_contact",
|
||||
)
|
||||
|
||||
|
||||
DIARY_PARTS = ("morning", "evening")
|
||||
DIARY_KINDS = ("diary_morning", "diary_evening")
|
||||
|
||||
|
||||
def diary_config() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
|
||||
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
|
||||
|
||||
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
|
||||
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
|
||||
so diary answers never mix with the full scales.
|
||||
"""
|
||||
if part not in DIARY_PARTS:
|
||||
raise ValueError("unknown diary part")
|
||||
config = diary_config()
|
||||
questions = [
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_week" if item.get("weekly") else "past_day",
|
||||
"past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
item.get("max", 24),
|
||||
)
|
||||
for item in config[part]
|
||||
]
|
||||
if part == "evening":
|
||||
forms = content()["forms"]
|
||||
if "depression" in modules:
|
||||
module = config["depression"]
|
||||
items = forms[module["form"]]["items"]
|
||||
per_day = module["items_per_day"]
|
||||
start = (day_index % (len(items) // per_day)) * per_day
|
||||
options = tuple((o["value"], o["label"]) for o in module["options"])
|
||||
for item in items[start : start + per_day]:
|
||||
number = item["id"].rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
|
||||
)
|
||||
if "veteran" in modules:
|
||||
module = config["ptsd"]
|
||||
form = forms[module["form"]]
|
||||
by_id = {i["id"]: i["text"] for i in form["items"]}
|
||||
options = tuple((o["value"], o["label"]) for o in form["options"])
|
||||
for item_id in module["items"]:
|
||||
number = item_id.rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
|
||||
)
|
||||
questions.sort(key=lambda q: q.id != "death_thoughts")
|
||||
return tuple(questions)
|
||||
|
||||
+81
-29
@@ -10,7 +10,7 @@ import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.questionnaire import Approval, load_policy
|
||||
from app.core.questionnaire import Approval, demo_mode, load_policy
|
||||
|
||||
|
||||
class RuntimePolicy(BaseModel):
|
||||
@@ -21,13 +21,20 @@ class RuntimePolicy(BaseModel):
|
||||
content_review: Approval | None = None
|
||||
contact_review: Approval | None = None
|
||||
timezone: str
|
||||
daily_hour: int = Field(ge=0, le=23)
|
||||
mood_baseline_days: int = Field(ge=2)
|
||||
mood_bad_days: int = Field(ge=1)
|
||||
mood_drop: int = Field(ge=1, le=10)
|
||||
morning_hour: int = Field(ge=0, le=23)
|
||||
evening_hour: int = Field(ge=0, le=23)
|
||||
norm_days: int = Field(ge=2)
|
||||
week_days: int = Field(ge=2)
|
||||
min_norm_values: int = Field(ge=1)
|
||||
min_week_values: int = Field(ge=1)
|
||||
mood_drop: float = Field(gt=0, le=10)
|
||||
depression_item_rise: float = Field(gt=0, le=3)
|
||||
ptsd_sum_rise: float = Field(gt=0, le=16)
|
||||
sleep_bad_days: int = Field(ge=1)
|
||||
sleep_hours_below: float = Field(gt=0, le=24)
|
||||
nightmares_at: int = Field(ge=0, le=4)
|
||||
alcohol_heavy_portions: float = Field(gt=0)
|
||||
alcohol_heavy_days: int = Field(ge=1)
|
||||
adherence_below: float = Field(gt=0, le=1)
|
||||
missed_medication_days: int = Field(ge=1)
|
||||
high_risk_silence_days: int = Field(ge=1)
|
||||
medication_days: tuple[int, ...]
|
||||
@@ -44,6 +51,8 @@ class RuntimePolicy(BaseModel):
|
||||
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
|
||||
):
|
||||
raise ValueError("all route and scale intervals must be defined")
|
||||
if self.morning_hour >= self.evening_hour:
|
||||
raise ValueError("the morning diary must come before the evening one")
|
||||
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
|
||||
raise ValueError("medication days must be nonempty and strictly increasing")
|
||||
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
|
||||
@@ -64,8 +73,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy:
|
||||
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
|
||||
|
||||
|
||||
def demo_intake() -> bool:
|
||||
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
|
||||
|
||||
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
|
||||
until the approvals are recorded there.
|
||||
"""
|
||||
return demo_mode()
|
||||
|
||||
|
||||
def intake_enabled() -> bool:
|
||||
return load_runtime().released and load_policy().approval is not None
|
||||
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
|
||||
|
||||
|
||||
def month_before(day: date, months: int = 1) -> date:
|
||||
@@ -115,39 +133,73 @@ def due_scales(
|
||||
return tuple(sorted(due))
|
||||
|
||||
|
||||
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
|
||||
"""Consecutive calendar dates; missing data never become zero or missed doses.
|
||||
def _mean(values: list[float]) -> float:
|
||||
return sum(values) / len(values)
|
||||
|
||||
Baseline is the seven dates BEFORE the three-day deterioration window;
|
||||
every baseline value is required. Nightmares OR short sleep must hold on
|
||||
each of three nights. These candidate definitions require signed review.
|
||||
|
||||
def diary_flags(
|
||||
history: dict[date, dict],
|
||||
today: date,
|
||||
policy: RuntimePolicy | None = None,
|
||||
*,
|
||||
medication_active: bool = False,
|
||||
) -> tuple[str, ...]:
|
||||
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
|
||||
|
||||
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
|
||||
observation; trends are checked only after it. Missing data never become zero or missed doses.
|
||||
"""
|
||||
p = policy or load_runtime()
|
||||
flags = []
|
||||
flags: list[str] = []
|
||||
current = history.get(today, {})
|
||||
if current.get("death_thoughts") == 1:
|
||||
flags.append("suicide_assessment_now")
|
||||
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
|
||||
baseline = [
|
||||
history.get(today - timedelta(days=i), {}).get("mood")
|
||||
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
|
||||
]
|
||||
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
|
||||
mean = sum(baseline) / len(baseline)
|
||||
if all(mean - x["mood"] >= p.mood_drop for x in recent):
|
||||
start = min(history) if history else today
|
||||
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
|
||||
week_start = today - timedelta(days=p.week_days - 1)
|
||||
week = [history[d] for d in sorted(history) if week_start <= d <= today]
|
||||
trend = (week_start - start).days >= p.norm_days
|
||||
|
||||
def values(rows: list[dict], key) -> list[float]:
|
||||
return [v for r in rows if (v := key(r)) is not None]
|
||||
|
||||
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
|
||||
before, now = values(norm, key), values(week, key)
|
||||
if not trend or len(before) < min_norm or len(now) < min_week:
|
||||
return False
|
||||
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
|
||||
return change >= by
|
||||
|
||||
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
|
||||
flags.append("phq9_due_mood")
|
||||
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
|
||||
for item in range(1, 10):
|
||||
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
|
||||
flags.append("phq9_due_depression")
|
||||
break
|
||||
# PTSD module: only the sum of the four items is interpreted, never single clusters.
|
||||
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
|
||||
|
||||
def ptsd_sum(r):
|
||||
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
|
||||
|
||||
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
|
||||
flags.append("pcl5_due_ptsd")
|
||||
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
|
||||
if all(
|
||||
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
|
||||
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
|
||||
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
|
||||
for r in nights
|
||||
):
|
||||
flags.append("sleep_review")
|
||||
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
|
||||
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
|
||||
flags.append("alcohol_on_medication")
|
||||
if len(heavy) >= p.alcohol_heavy_days:
|
||||
flags.append("rus_audit_due_alcohol")
|
||||
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
|
||||
if all(r.get("medication") == "none" for r in doses):
|
||||
if all(r.get("medication") == "no" for r in doses):
|
||||
flags.append("medication_contact")
|
||||
if current.get("side_effects") == 1:
|
||||
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
|
||||
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
|
||||
flags.append("adherence_review")
|
||||
if current.get("medication_reason") == "side_effects":
|
||||
flags.append("side_effects_review")
|
||||
if current.get("medication") == "partial":
|
||||
flags.append("partial_medication_review")
|
||||
return tuple(flags)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
|
||||
|
||||
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
|
||||
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
|
||||
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.intake_questions import content
|
||||
from app.core.questionnaire import IntakeDecision, IntakePolicy
|
||||
|
||||
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
|
||||
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
|
||||
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
|
||||
PHQ9_REMISSION_BELOW = 5
|
||||
PHQ9_IMPROVED_BY = 5
|
||||
PCL5_RESPONSE_BY = 10
|
||||
PCL5_IMPROVED_BELOW = 28
|
||||
|
||||
|
||||
def texts() -> dict[str, Any]:
|
||||
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render(interpretation: dict[str, Any]) -> str:
|
||||
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
|
||||
parts = [interpretation["title"], *interpretation.get("lines", [])]
|
||||
if interpretation.get("disclaimer"):
|
||||
parts.append(interpretation["disclaimer"])
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _alcohol_zone(totals: dict) -> int | None:
|
||||
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
|
||||
return full.get("zone") if full.get("complete") else short.get("zone")
|
||||
|
||||
|
||||
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
|
||||
"""Screening findings of stage 1, in the order they are shown."""
|
||||
found = []
|
||||
phq = totals.get("phq9") or {}
|
||||
if phq.get("complete"):
|
||||
if phq["total"] >= policy.phq9_specialist_at:
|
||||
found.append("depression_suspected")
|
||||
elif phq["total"] >= policy.phq9_psychologist_at:
|
||||
found.append("depression_mild")
|
||||
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
|
||||
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
|
||||
found.append("ptsd_suspected")
|
||||
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
|
||||
found.append("ptsd_signs")
|
||||
zone = _alcohol_zone(totals)
|
||||
if zone == 4:
|
||||
found.append("alcohol_high")
|
||||
elif zone in (2, 3):
|
||||
found.append("alcohol_risky")
|
||||
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
|
||||
found.append("suicide_low")
|
||||
if answers.get("tbi") == 1:
|
||||
found.append("tbi")
|
||||
return found
|
||||
|
||||
|
||||
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
|
||||
t = texts()
|
||||
found = intake_findings(totals, answers, policy)
|
||||
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
|
||||
head = t["intake"]["attention" if found else "ok"]
|
||||
lines = [head["text"]]
|
||||
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
|
||||
if decision.primary_route in t["routes"]:
|
||||
lines.append(t["routes"][decision.primary_route])
|
||||
names = ", ".join(t["modules"]["names"][m] for m in modules)
|
||||
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
|
||||
return {
|
||||
"kind": "intake",
|
||||
"title": head["title"],
|
||||
"lines": lines,
|
||||
"codes": found or ["ok"],
|
||||
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
|
||||
"route": decision.primary_route,
|
||||
"modules": modules,
|
||||
"disclaimer": t["disclaimer"],
|
||||
}
|
||||
|
||||
|
||||
def _phq9_level(total: int) -> str:
|
||||
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
|
||||
return band["code"]
|
||||
|
||||
|
||||
def scale_interpretation(
|
||||
kind: str,
|
||||
totals: dict,
|
||||
*,
|
||||
baseline: int | None,
|
||||
previous: int | None,
|
||||
policy: IntakePolicy,
|
||||
retriage_delta: int,
|
||||
) -> dict:
|
||||
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
|
||||
t = texts()["scales"]
|
||||
if kind == "rus_audit_s":
|
||||
zone = _alcohol_zone(totals)
|
||||
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
|
||||
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
|
||||
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
|
||||
return {
|
||||
"kind": kind,
|
||||
"title": t[kind]["title"],
|
||||
"lines": [line],
|
||||
"codes": [f"zone_{zone}" if zone else "unknown"],
|
||||
}
|
||||
total = totals[kind]["total"]
|
||||
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
|
||||
code = _phq9_level(total) if kind == "phq9" else pcl5_level
|
||||
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
|
||||
change = None
|
||||
if previous is not None and total - previous >= retriage_delta:
|
||||
change = "worse"
|
||||
elif baseline is not None:
|
||||
if kind == "phq9":
|
||||
if total < PHQ9_REMISSION_BELOW:
|
||||
change = "remission"
|
||||
elif baseline > 0 and total <= baseline / 2:
|
||||
change = "response"
|
||||
elif baseline - total >= PHQ9_IMPROVED_BY:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
else:
|
||||
if baseline - total >= PCL5_RESPONSE_BY:
|
||||
change = "response"
|
||||
elif total < PCL5_IMPROVED_BELOW <= baseline:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
if change:
|
||||
lines.append(t["change"][change])
|
||||
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
|
||||
|
||||
|
||||
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
|
||||
t = texts()["diary"]
|
||||
shown = [f for f in flags if f in t["flags"]]
|
||||
return {
|
||||
"kind": f"diary_{part}",
|
||||
"title": t["saved"].format(part=t["parts"][part]),
|
||||
"lines": [t["flags"][f] for f in shown],
|
||||
"codes": list(flags),
|
||||
}
|
||||
|
||||
|
||||
def cssrs_interpretation(risk: str | None) -> dict:
|
||||
t = texts()["cssrs"]
|
||||
return {
|
||||
"kind": "cssrs_since_contact",
|
||||
"title": t["low" if risk == "low" else "none"],
|
||||
"lines": [],
|
||||
"codes": [risk or "none"],
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py).
|
||||
|
||||
Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long
|
||||
the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the
|
||||
doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file).
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
Indication = Literal["depression", "ptsd"]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_reference() -> dict[str, Any]:
|
||||
path = get_settings().data_dir / "meds" / "meds_reference.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def find_drug(name: str) -> dict[str, Any] | None:
|
||||
"""Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered."""
|
||||
key = name.strip().split()[0].casefold() if name.strip() else ""
|
||||
for drug in load_reference()["drugs"]:
|
||||
if key in (drug["id"], drug["name_ru"].casefold()):
|
||||
return drug
|
||||
return None
|
||||
|
||||
|
||||
def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None:
|
||||
"""The indication the patient is followed for, among those the drug has in the guidelines."""
|
||||
preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"]
|
||||
return next((i for i in preferred if i in drug["indications"]), None)
|
||||
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -69,8 +70,17 @@ class IntakePolicy(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def demo_mode() -> bool:
|
||||
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
|
||||
|
||||
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
|
||||
real policy and the release gate in config/intake_runtime.yaml are never touched.
|
||||
"""
|
||||
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
|
||||
|
||||
def load_policy(path: Path | None = None) -> IntakePolicy:
|
||||
path = path or get_settings().config_dir / "questionnaire.yaml"
|
||||
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
|
||||
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ The only module (besides registration code) that touches both schemas — and on
|
||||
|
||||
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
|
||||
from app.db import intake as _intake # noqa: F401
|
||||
from app.db import scheduler as _scheduler # noqa: F401
|
||||
from app.db.clinical import ClinicalBase
|
||||
from app.db.identity import IdentityBase
|
||||
from app.db.session import clinical_engine, identity_engine
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Delivery log of the demo scheduler (#13): one row per delivered event, the unique key makes runs idempotent.
|
||||
|
||||
Clinical DB, keyed by token only (guardrail #5). Created by `init_db` (`app.db.bootstrap`).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.clinical import TOKEN_LEN, BigId, ClinicalBase
|
||||
from app.db.types import TZDateTime
|
||||
|
||||
|
||||
class SchedulerEvent(ClinicalBase):
|
||||
__tablename__ = "scheduler_events"
|
||||
__table_args__ = (UniqueConstraint("token", "key", name="uq_scheduler_events_token_key"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
|
||||
token: Mapped[str] = mapped_column(String(TOKEN_LEN))
|
||||
key: Mapped[str] = mapped_column(String(96)) # e.g. "dose:3:2026-10-06T08:00", "checkin:2026-10-06"
|
||||
kind: Mapped[str] = mapped_column(String(32)) # med_reminder | missed | daily_checkin | scale_due | evaluation
|
||||
due_at: Mapped[datetime] = mapped_column(TZDateTime)
|
||||
done_at: Mapped[datetime] = mapped_column(TZDateTime)
|
||||
+15
-3
@@ -6,7 +6,7 @@ are ignored here. While a crisis is open only crisis-relevant actions (crisis, s
|
||||
the patient: no medication tips or visit offers in the middle of a crisis.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from typing import Any
|
||||
|
||||
from app.core.rules import Action
|
||||
@@ -20,6 +20,16 @@ from app.dialog.scale_session import active_scale, add_pending
|
||||
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
|
||||
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
|
||||
|
||||
# Optional `med_info` answerer registered by a flow (`flows/meds.py`, #37): answers from the drug instruction
|
||||
# (med_qa). Returning None falls back to the phrase template below.
|
||||
MedInfoHandler = Callable[[Ctx, Action], Awaitable[Reply | None]]
|
||||
_med_info_handler: MedInfoHandler | None = None
|
||||
|
||||
|
||||
def register_med_info_handler(handler: MedInfoHandler | None) -> None:
|
||||
global _med_info_handler
|
||||
_med_info_handler = handler
|
||||
|
||||
|
||||
def _plan_line(step: Any) -> str:
|
||||
if isinstance(step, dict):
|
||||
@@ -57,9 +67,11 @@ async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
|
||||
pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False)))
|
||||
step = step.then(Step(data={"pending_scales": pending}))
|
||||
elif action.type == "med_info":
|
||||
reply = await _med_info_handler(ctx, action) if _med_info_handler is not None else None
|
||||
if reply is None:
|
||||
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
|
||||
text = await ctx.gateway.phrase(intent, {})
|
||||
step = step.then(Step(replies=[Reply(text=text, kind="info")]))
|
||||
reply = Reply(text=await ctx.gateway.phrase(intent, {}), kind="info")
|
||||
step = step.then(Step(replies=[reply]))
|
||||
elif action.type == "suggest_visit":
|
||||
text = await ctx.gateway.phrase("book_visit_offer", {})
|
||||
button = Button(label=ctx.texts.visit.button, data="menu:visit")
|
||||
|
||||
+50
-3
@@ -2,6 +2,10 @@
|
||||
|
||||
Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
|
||||
|
||||
0. abandoned step the FSM sits in a question step (check-in, scale) the patient left more than
|
||||
`settings.abandon_after_hours` ago → back to idle first (stored answers stay; never in
|
||||
crisis, never in an onboarding chain), then the event is handled as usual.
|
||||
|
||||
text event
|
||||
1. scrub() raw text → ScrubbedText. Raw text is never stored, logged or sent anywhere.
|
||||
2. consent gate no valid consent → no LLM, nothing stored; only the local crisis lexicon runs
|
||||
@@ -25,7 +29,7 @@ Logs carry only the token, states and event codes (guardrail #8).
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
@@ -45,7 +49,16 @@ from app.dialog.contracts import Event, Reply, register_engine
|
||||
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
|
||||
from app.dialog.flows import load_flows
|
||||
from app.dialog.flows.core import close_crisis, revoke
|
||||
from app.dialog.fsm import NO_CONSENT_STATES, RESTING_STATES, Ctx, Step, apply_data, enter_hook_for, handler_for
|
||||
from app.dialog.fsm import (
|
||||
NO_CONSENT_STATES,
|
||||
RESTING_STATES,
|
||||
Ctx,
|
||||
Step,
|
||||
apply_data,
|
||||
enter_hook_for,
|
||||
handler_for,
|
||||
is_abandonable,
|
||||
)
|
||||
from app.dialog.texts import DialogTexts, load_dialog_texts
|
||||
from app.llm.gateway import LLMGateway, get_gateway
|
||||
from app.services import questionnaire as intake_service
|
||||
@@ -58,6 +71,8 @@ MAX_ENTER_CHAIN = 8
|
||||
_patient_locks = WeakValueDictionary()
|
||||
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
|
||||
_DB_DOWN = (OSError, OperationalError, InterfaceError)
|
||||
# dialog_state.data key: when the patient last acted in (or the FSM entered) the current step — abandon timeout.
|
||||
STEP_AT = "step_at"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -109,6 +124,8 @@ class Engine:
|
||||
state_before = ctx.state
|
||||
run = _Run()
|
||||
|
||||
if self._abandoned(ctx):
|
||||
await self._apply(ctx, self._abandon(ctx), run)
|
||||
await self._apply(ctx, await self._route(ctx, event), run)
|
||||
|
||||
if run.notify:
|
||||
@@ -126,6 +143,9 @@ class Engine:
|
||||
await repo.add_message(
|
||||
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
|
||||
)
|
||||
if event.type != "system" or ctx.state != state_before:
|
||||
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
|
||||
ctx.data[STEP_AT] = ctx.now.isoformat()
|
||||
await self._save_state(ctx)
|
||||
task_id = event.params.get("questionnaire_task")
|
||||
if event.type == "system" and task_id and run.replies:
|
||||
@@ -145,6 +165,24 @@ class Engine:
|
||||
)
|
||||
return run.replies
|
||||
|
||||
def _abandoned(self, ctx: Ctx) -> bool:
|
||||
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
|
||||
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
|
||||
return False
|
||||
hours = ctx.texts.settings.abandon_after_hours
|
||||
last = ctx.data.get(STEP_AT)
|
||||
if not hours or not last or ctx.now - datetime.fromisoformat(last) < timedelta(hours=hours):
|
||||
return False
|
||||
return is_abandonable(ctx)
|
||||
|
||||
def _abandon(self, ctx: Ctx) -> Step:
|
||||
"""Drop the unfinished step's progress (answers already stored in item_responses / daily_checkins stay;
|
||||
an unfinished scale gets no scale_results row) and rest in idle so queued scales may start."""
|
||||
log.info("dialog step abandoned token=%s state=%s", ctx.token, ctx.state)
|
||||
scale_id = scale_session.active_scale(ctx.state)
|
||||
scales = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale_id}
|
||||
return Step(next_state="idle", data={"checkin": None, "scales": scales or None})
|
||||
|
||||
async def _route(self, ctx: Ctx, event: Event) -> Step:
|
||||
consented = _consented(ctx)
|
||||
if event.type == "text":
|
||||
@@ -307,10 +345,19 @@ class Engine:
|
||||
if not pending or ctx.state not in RESTING_STATES:
|
||||
return Step()
|
||||
screen = ctx.texts.settings.risk_screen_scale
|
||||
dropped: dict[str, Any] = {}
|
||||
if any(p["scale"] == screen for p in pending) and await scale_session.recent_risk_screen(ctx, screen):
|
||||
# Item 9 / rule phq9_item9 / scale_due asked for a C-SSRS the patient completed within the cooldown:
|
||||
# do not repeat the same suicide screen (#56). Crisis script and alerts are unaffected.
|
||||
log.info("dialog risk screen skipped (cooldown) token=%s", ctx.token)
|
||||
pending = [p for p in pending if p["scale"] != screen]
|
||||
dropped = {"pending_scales": pending or None}
|
||||
if not pending:
|
||||
return Step(data=dropped)
|
||||
if ctx.state == "crisis":
|
||||
pending_screen = [p for p in pending if p["scale"] == screen]
|
||||
if not pending_screen:
|
||||
return Step()
|
||||
return Step(data=dropped)
|
||||
item = pending_screen[0]
|
||||
else:
|
||||
item = pending[0]
|
||||
|
||||
@@ -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")],
|
||||
]
|
||||
@@ -69,7 +70,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
modules = list(profile.modules) if profile is not None and profile.modules else []
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
|
||||
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
|
||||
# person has no profile at all before it.
|
||||
if after is None and intake_enabled():
|
||||
return "intake.start"
|
||||
if after in modules:
|
||||
modules = modules[modules.index(after) + 1 :]
|
||||
|
||||
+123
-49
@@ -4,9 +4,13 @@ import math
|
||||
from dataclasses import asdict, replace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core import interpretation
|
||||
from app.core.intake_questions import (
|
||||
DIARY_KINDS,
|
||||
Question,
|
||||
content,
|
||||
cssrs_answers,
|
||||
cssrs_since_contact_question,
|
||||
diary_questions,
|
||||
group_answers,
|
||||
next_intake,
|
||||
@@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
|
||||
from app.core.scales import load_scale
|
||||
from app.core.types import AlertLevel
|
||||
from app.db import repo
|
||||
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
@@ -28,21 +33,23 @@ def allowed(ctx):
|
||||
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
|
||||
|
||||
|
||||
def side_effects_question():
|
||||
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
|
||||
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
|
||||
|
||||
|
||||
def next_question(row):
|
||||
a = row.answers
|
||||
if row.kind == "intake":
|
||||
return next_intake(a, load_policy(), row.details.get("sex"))
|
||||
if row.kind == "diary":
|
||||
for q in diary_questions(
|
||||
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
|
||||
):
|
||||
if row.kind == "cssrs_since_contact":
|
||||
return cssrs_since_contact_question(a)
|
||||
if row.kind in DIARY_KINDS:
|
||||
part = row.kind.removeprefix("diary_")
|
||||
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
|
||||
if q.id in a:
|
||||
continue
|
||||
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
|
||||
continue
|
||||
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
|
||||
continue
|
||||
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
|
||||
if q.id == "medication_reason" and a.get("medication") != "no":
|
||||
continue
|
||||
return q
|
||||
return None
|
||||
@@ -63,11 +70,7 @@ def next_question(row):
|
||||
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
|
||||
return q
|
||||
if row.details.get("medication_active") and "side_effects" not in a:
|
||||
return next(
|
||||
q
|
||||
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
|
||||
if q.id == "side_effects"
|
||||
)
|
||||
return side_effects_question()
|
||||
return None
|
||||
|
||||
|
||||
@@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
flags = list(service.decision(row).flags) if row else []
|
||||
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
|
||||
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
|
||||
flags.append("cssrs_since_contact_required")
|
||||
if row:
|
||||
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
|
||||
@@ -132,7 +135,8 @@ async def advance(ctx, row):
|
||||
a = row.answers
|
||||
c = score_cssrs_recent(cssrs_answers(a))
|
||||
policy = load_policy()
|
||||
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
|
||||
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
|
||||
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
|
||||
return await urgent(ctx, row, code="suicide_signal")
|
||||
if c.positive and policy.cssrs_positive_policy == "stop_any":
|
||||
return await urgent(ctx, row, code="cssrs_positive")
|
||||
@@ -153,9 +157,6 @@ async def advance(ctx, row):
|
||||
ctx.now,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
if row.kind == "cssrs_since_contact":
|
||||
# Recent and Since Last Contact are different instruments. No silent substitution.
|
||||
return await manual_review(ctx, row, "cssrs_since_contact_required")
|
||||
try:
|
||||
q = next_question(row)
|
||||
# Validate preserved short answers against a newly answered full-form item 1.
|
||||
@@ -187,6 +188,8 @@ async def advance(ctx, row):
|
||||
extra = {"decision": asdict(result), "totals": service.summary(row)}
|
||||
if a.get("recent_trauma") == 1:
|
||||
extra["recent_trauma"] = True
|
||||
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
|
||||
await apply_modules(ctx, extra["interpretation"]["modules"])
|
||||
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
@@ -196,23 +199,29 @@ async def advance(ctx, row):
|
||||
ctx.now,
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
elif row.kind == "diary":
|
||||
history = {
|
||||
r.day: r.answers
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.kind == "diary" and r.status == "complete"
|
||||
}
|
||||
history[row.day] = a
|
||||
flags = diary_flags(history, row.day)
|
||||
elif row.kind == "cssrs_since_contact":
|
||||
extra["totals"] = {"cssrs_recent": asdict(c)}
|
||||
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
|
||||
elif row.kind in DIARY_KINDS:
|
||||
# Morning and evening answers of one date form one diary day.
|
||||
history: dict = {}
|
||||
for r in await service.records(ctx.session, ctx.token):
|
||||
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
|
||||
history.setdefault(r.day, {}).update(r.answers)
|
||||
history.setdefault(row.day, {}).update(a)
|
||||
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
|
||||
extra["flags"] = flags
|
||||
for flag in flags:
|
||||
await service.alert(
|
||||
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
|
||||
)
|
||||
if flag == "phq9_due_mood":
|
||||
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
|
||||
if flag in DIARY_SCALE_TASKS:
|
||||
sid = DIARY_SCALE_TASKS[flag]
|
||||
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
|
||||
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
|
||||
else:
|
||||
extra["totals"] = service.summary(row)
|
||||
runtime = load_runtime()
|
||||
if row.kind in ("phq9", "pcl5"):
|
||||
sid = row.kind
|
||||
total = extra["totals"][sid]["total"]
|
||||
@@ -223,27 +232,37 @@ async def advance(ctx, row):
|
||||
if total >= priority
|
||||
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
|
||||
)
|
||||
prior = next(
|
||||
(
|
||||
earlier = [
|
||||
r
|
||||
for r in reversed(await service.records(ctx.session, ctx.token))
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.id != row.id
|
||||
and r.status == "complete"
|
||||
and r.version == row.version
|
||||
and r.details.get("totals", {}).get(sid, {}).get("complete")
|
||||
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
|
||||
),
|
||||
None,
|
||||
)
|
||||
]
|
||||
prior = earlier[-1] if earlier else None
|
||||
if prior:
|
||||
change = total - prior.details["totals"][sid]["total"]
|
||||
extra["delta"] = {
|
||||
"baseline_record": prior.id,
|
||||
"value": change,
|
||||
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
|
||||
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
|
||||
}
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
sid,
|
||||
extra["totals"],
|
||||
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
|
||||
previous=prior.details["totals"][sid]["total"] if prior else None,
|
||||
policy=policy,
|
||||
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
|
||||
)
|
||||
if row.kind == "rus_audit_s" and not row.details.get("sex"):
|
||||
return await manual_review(ctx, row, "alcohol_sex_unknown")
|
||||
if row.kind == "rus_audit_s":
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
|
||||
)
|
||||
if extra["totals"]["rus_audit"].get("zone") == 4:
|
||||
extra["parallel_routes"] = ["narcologist"]
|
||||
retriage = extra.get("delta", {}).get("retriage", False)
|
||||
@@ -257,13 +276,48 @@ async def advance(ctx, row):
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
await service.finish(ctx, row, "complete", extra=extra)
|
||||
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
|
||||
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
|
||||
# The risk questions open the evening diary; continue straight into it.
|
||||
step = await start(ctx, "diary_evening")
|
||||
step.replies.insert(0, Reply(text=shown, kind="info"))
|
||||
return step
|
||||
from app.dialog.flows.core import menu_reply
|
||||
|
||||
replies = [menu_reply(ctx, texts()["done"])]
|
||||
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
|
||||
if alcohol_total.get("zone") in (2, 3):
|
||||
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
|
||||
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
|
||||
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
|
||||
|
||||
|
||||
# Diary flags that bring a full scale forward («Этап 2», p.4).
|
||||
DIARY_SCALE_TASKS = {
|
||||
"phq9_due_mood": "phq9",
|
||||
"phq9_due_depression": "phq9",
|
||||
"pcl5_due_ptsd": "pcl5",
|
||||
"rus_audit_due_alcohol": "rus_audit_s",
|
||||
}
|
||||
|
||||
|
||||
async def apply_modules(ctx, modules):
|
||||
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
|
||||
(diagnosis from ЕМИАС) are kept and only extended."""
|
||||
profile = await ctx.profile()
|
||||
if profile is None:
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
|
||||
return
|
||||
current = list(profile.modules or [])
|
||||
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
|
||||
|
||||
|
||||
def local_day(ctx):
|
||||
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
|
||||
|
||||
async def done_today(ctx, kind):
|
||||
day = local_day(ctx)
|
||||
return any(
|
||||
r.kind == kind and r.day == day and r.status == "complete"
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
)
|
||||
|
||||
|
||||
async def start(ctx, kind="intake", task=None):
|
||||
@@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None):
|
||||
if task:
|
||||
return Step()
|
||||
return Step(replies=[Reply(text=texts()["review"], kind="info")])
|
||||
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
|
||||
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
|
||||
row = await service.create_record(ctx, kind, task=task)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -316,14 +372,28 @@ async def checkin(ctx, event):
|
||||
existing = await service.records(ctx.session, ctx.token)
|
||||
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
|
||||
return await start(ctx)
|
||||
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
|
||||
return Step(replies=[Reply(text=texts()["done"], kind="info")])
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
async def next_diary(ctx):
|
||||
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
|
||||
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
|
||||
day = local_day(ctx)
|
||||
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
|
||||
for kind in DIARY_KINDS:
|
||||
if kind in done:
|
||||
continue
|
||||
profile = await ctx.profile()
|
||||
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
if risk_task.status in ("pending", "active"):
|
||||
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, "diary")
|
||||
return await start(ctx, kind)
|
||||
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
|
||||
|
||||
|
||||
@trigger("button:diary:next")
|
||||
async def diary_button(ctx, event):
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
@state("intake")
|
||||
@@ -365,12 +435,16 @@ async def collect(ctx, event: Event):
|
||||
elif event.type == "text" and q.numeric:
|
||||
try:
|
||||
value = float(str(ctx.text).replace(",", "."))
|
||||
valid = math.isfinite(value) and 0 <= value <= 24
|
||||
valid = math.isfinite(value) and 0 <= value <= q.maximum
|
||||
except ValueError:
|
||||
pass
|
||||
elif event.type == "text" and q.optional:
|
||||
value, valid = str(ctx.text)[:2000], True
|
||||
if not valid:
|
||||
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
|
||||
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
|
||||
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
|
||||
service.save_answer(row, q, value, ctx.now)
|
||||
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
|
||||
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
|
||||
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Medication flow (#37, case scenario «управление приёмом лекарств»). Module-agnostic: every patient with a
|
||||
prescription gets it, whatever the clinical module.
|
||||
|
||||
system:med_reminder {dose_id, drug, dose}
|
||||
──▶ «Принял» → dose taken, check-in med_taken = true
|
||||
──▶ «Пропущу» → dose skipped, med_taken = false ──▶ reason:
|
||||
забыл / побочки (→ side effects) / решил бросить (quit_intent) / другое
|
||||
──▶ «Есть побочные эффекты» → code buttons (тошнота, бессонница, …) or «Другое» → free text
|
||||
→ check-in side_effects (+ dose.side_effects) → «А препарат сегодня приняли?»
|
||||
menu «Вопрос о лекарстве" ──▶ meds.question (free text) ──▶ med_qa (instruction excerpt) or «уточните у врача»
|
||||
rule action med_info (actions.py hook) ──▶ instruction excerpt for the rule topic + fixed notice
|
||||
|
||||
Everything the patient answers is written to the clinical DB (`doses`, `daily_checkins`) and the step asks the
|
||||
engine to run monitoring (`notify_monitoring`): the rules (lane B, ADR-007) decide alerts and further actions
|
||||
(quit intent → delayed-onset explanation, missed doses → caring contact). Nothing here decides risk.
|
||||
|
||||
Reminder buttons carry the dose they refer to (#56): "med:taken:<ref>", "med:skip:<ref>",
|
||||
"med:side_effects:<ref>" and the follow-up buttons likewise ("med:se:<code>:<ref>", "med:skip_reason:<r>:<ref>",
|
||||
"med:dose:yes:<ref>"), matched by prefix triggers. `<ref>` is the `doses.id` of the reminder or, when there is
|
||||
no dose row (prescription only known to the МИС), the reminder's ISO date. A button of an earlier day's dose is
|
||||
stale (friendly note, nothing recorded); so are the old payload-less buttons ("med:taken") of earlier releases.
|
||||
Check-in answers without a button (daily.med step, free text) go to today's planned dose, if any.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.privacy import scrub
|
||||
from app.core.rules import Action
|
||||
from app.db import repo
|
||||
from app.db.clinical import DailyCheckin, Dose
|
||||
from app.db.clinical import Prescription as PrescriptionRow
|
||||
from app.dialog.actions import register_med_info_handler
|
||||
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 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"
|
||||
# A client sends this button when the person submits a free-text step without writing anything.
|
||||
NO_COMMENT = "text:skip"
|
||||
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
|
||||
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- texts
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class ReminderTexts(_Strict):
|
||||
text: str
|
||||
text_no_drug: str
|
||||
taken: str
|
||||
skip: str
|
||||
side_effects: str
|
||||
|
||||
|
||||
class SkipTexts(_Strict):
|
||||
question: str
|
||||
reasons: dict[str, str]
|
||||
ack: dict[str, str]
|
||||
|
||||
|
||||
class SideEffectTexts(_Strict):
|
||||
question: str
|
||||
options: dict[str, str]
|
||||
questions: dict[str, str]
|
||||
describe: str
|
||||
ack: str
|
||||
repeat: str
|
||||
ask_dose: str
|
||||
dose_yes: str
|
||||
dose_no: str
|
||||
|
||||
|
||||
class CheckinMedTexts(_Strict):
|
||||
question: str
|
||||
question_no_drug: str
|
||||
yes: str
|
||||
no: str
|
||||
later: str
|
||||
|
||||
|
||||
class QuestionTexts(_Strict):
|
||||
prompt: str
|
||||
no_prescription: str
|
||||
|
||||
|
||||
class MedInfoTopic(_Strict):
|
||||
section: str # med_qa section code (side_effects | onset | alcohol | ...)
|
||||
question: str
|
||||
lead: str = ""
|
||||
fallback: str
|
||||
notice: str = ""
|
||||
|
||||
|
||||
class MedsTexts(_Strict):
|
||||
status: str
|
||||
reminder: ReminderTexts
|
||||
taken_ack: str
|
||||
stale: str
|
||||
skip: SkipTexts
|
||||
side_effects: SideEffectTexts
|
||||
checkin: CheckinMedTexts
|
||||
question: QuestionTexts
|
||||
med_info: dict[str, MedInfoTopic]
|
||||
|
||||
|
||||
def meds_texts_path() -> Path:
|
||||
return get_settings().config_dir / "texts" / "meds.yaml"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_meds_texts(path: Path | None = None) -> MedsTexts:
|
||||
path = path or meds_texts_path()
|
||||
return MedsTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- data helpers
|
||||
|
||||
|
||||
class Rx(BaseModel):
|
||||
"""Active prescription as the dialog needs it (drug INN + dose exactly as the doctor wrote them)."""
|
||||
|
||||
id: int | None
|
||||
drug: str
|
||||
dose: str
|
||||
|
||||
|
||||
async def current_prescription(ctx: Ctx) -> Rx | None:
|
||||
"""Latest prescription active today: clinical DB first, then the МИС integration (demo: in-memory)."""
|
||||
today = ctx.now.date()
|
||||
rows = await ctx.session.scalars(
|
||||
select(PrescriptionRow).where(PrescriptionRow.token == ctx.token).order_by(PrescriptionRow.id.desc())
|
||||
)
|
||||
for row in rows:
|
||||
if (row.start_date is None or row.start_date <= today) and (row.end_date is None or row.end_date >= today):
|
||||
return Rx(id=row.id, drug=row.drug, dose=row.dose)
|
||||
for p in await integrations.prescriptions.get_prescriptions(ctx.token):
|
||||
if (p.start_date is None or p.start_date <= today) and (p.end_date is None or p.end_date >= today):
|
||||
return Rx(id=None, drug=p.drug, dose=p.dose)
|
||||
return None
|
||||
|
||||
|
||||
async def checkin_of(ctx: Ctx, day: date) -> DailyCheckin | None:
|
||||
return await ctx.session.scalar(
|
||||
select(DailyCheckin).where(DailyCheckin.token == ctx.token, DailyCheckin.day == day)
|
||||
)
|
||||
|
||||
|
||||
async def save_checkin(ctx: Ctx, **fields: Any) -> DailyCheckin:
|
||||
return await repo.upsert_checkin(ctx.session, token=ctx.token, day=ctx.now.date(), now=ctx.now, **fields)
|
||||
|
||||
|
||||
def dose_ref(dose_id: int | None, day: date) -> str:
|
||||
"""Button payload naming a dose: its row id, or the reminder day when there is no row."""
|
||||
return str(dose_id) if dose_id is not None else day.isoformat()
|
||||
|
||||
|
||||
async def todays_dose(ctx: Ctx) -> Dose | None:
|
||||
"""Latest dose row planned for today (local day of `ctx.now`) — target of answers given without a button."""
|
||||
rows = await ctx.session.scalars(select(Dose).where(Dose.token == ctx.token).order_by(Dose.id.desc()).limit(20))
|
||||
today = ctx.now.date()
|
||||
return next((r for r in rows if r.planned_at.astimezone(ctx.now.tzinfo).date() == today), None)
|
||||
|
||||
|
||||
async def resolve_ref(ctx: Ctx, ref: str | None) -> tuple[bool, Dose | None]:
|
||||
"""(fresh, dose row) for a button payload. Not fresh: missing/foreign/unknown ref or a dose of another day."""
|
||||
if not ref:
|
||||
return False, None
|
||||
if ref.isdigit():
|
||||
row = await ctx.session.get(Dose, int(ref))
|
||||
if row is None or row.token != ctx.token:
|
||||
return False, None
|
||||
return row.planned_at.astimezone(ctx.now.tzinfo).date() == ctx.now.date(), row
|
||||
try:
|
||||
return date.fromisoformat(ref) == ctx.now.date(), None
|
||||
except ValueError:
|
||||
return False, None
|
||||
|
||||
|
||||
async def _dose_row(ctx: Ctx, ref: str | None) -> Dose | None:
|
||||
if ref is None:
|
||||
return await todays_dose(ctx)
|
||||
return (await resolve_ref(ctx, ref))[1]
|
||||
|
||||
|
||||
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None, ref: str | None = None) -> None:
|
||||
"""Dose answer → `doses` row (`ref` from the button, else today's) + `daily_checkins.med_taken` (rules read
|
||||
both)."""
|
||||
row = await _dose_row(ctx, ref)
|
||||
if row is not None:
|
||||
row.status = status or ("taken" if taken else "skipped")
|
||||
row.answered_at = ctx.now
|
||||
await save_checkin(ctx, med_taken=taken)
|
||||
|
||||
|
||||
async def record_side_effects(ctx: Ctx, codes: list[str], *, ref: str | None = None) -> None:
|
||||
"""Merge side-effect codes into today's check-in (rule `side_effect_3d` counts days with any code)."""
|
||||
today = await checkin_of(ctx, ctx.now.date())
|
||||
merged = list(dict.fromkeys([*(today.side_effects if today and today.side_effects else []), *codes]))
|
||||
await save_checkin(ctx, side_effects=merged)
|
||||
row = await _dose_row(ctx, ref)
|
||||
if row is not None:
|
||||
row.side_effects = ",".join(c for c in dict.fromkeys([*(row.side_effects or "").split(","), *codes]) if c)
|
||||
|
||||
|
||||
async def side_effect_reply(ctx: Ctx, codes: list[str]) -> Reply:
|
||||
"""Reaction to newly reported side effects.
|
||||
|
||||
Same effect as yesterday → short support (the instruction was already shown; persistence over 3 days is the
|
||||
rules' business). New effect with a known instruction question → the excerpt of the drug instruction
|
||||
(med_qa, section side_effects) — never generated advice. Otherwise a plain acknowledgement.
|
||||
"""
|
||||
t = load_meds_texts().side_effects
|
||||
yesterday = await checkin_of(ctx, ctx.now.date() - timedelta(days=1))
|
||||
if yesterday is not None and set(codes) & set(yesterday.side_effects or []):
|
||||
return Reply(text=t.repeat, kind="info")
|
||||
rx = await current_prescription(ctx)
|
||||
for code in codes:
|
||||
if code in t.questions and rx is not None:
|
||||
answer = await med_qa(rx.drug, scrub(t.questions[code]), topic="side_effects")
|
||||
if answer is not None:
|
||||
return Reply(text=answer.text, kind="info")
|
||||
return Reply(text=t.ack, kind="info")
|
||||
|
||||
|
||||
async def leave_to_idle(ctx: Ctx, event: Event, data: dict[str, Any] | None = None) -> Step:
|
||||
"""Abandon the current sub-flow and let the idle state handle the event (menu button, system event...)."""
|
||||
step = Step(next_state="idle", data=data or {})
|
||||
idle = handler_for("idle")
|
||||
return step.then(await idle(ctx, event)) if idle is not None else step
|
||||
|
||||
|
||||
async def run_trigger_inline(ctx: Ctx, key: str, event: Event) -> Step | None:
|
||||
"""Run an idle trigger from inside another flow (e.g. a reminder button pressed during the check-in)."""
|
||||
handler = get_trigger(key)
|
||||
return await handler(ctx, event) if handler is not None else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- reminder
|
||||
|
||||
|
||||
def _reminder_buttons(ref: str) -> list[list[Button]]:
|
||||
t = load_meds_texts().reminder
|
||||
return [
|
||||
[Button(label=t.taken, data=f"med:taken:{ref}")],
|
||||
[Button(label=t.skip, data=f"med:skip:{ref}")],
|
||||
[Button(label=t.side_effects, data=f"med:side_effects:{ref}")],
|
||||
]
|
||||
|
||||
|
||||
@trigger("system:med_reminder", replace=True)
|
||||
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
|
||||
t = load_meds_texts().reminder
|
||||
rx = await current_prescription(ctx)
|
||||
drug = str(event.params.get("drug") or (rx.drug if rx else ""))
|
||||
dose = str(event.params.get("dose") or (rx.dose if rx else ""))
|
||||
try:
|
||||
dose_id: int | None = int(event.params["dose_id"]) # type: ignore[call-overload]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
dose_id = None
|
||||
if dose_id is None and rx is not None and rx.id is not None:
|
||||
# No scheduler row (demo console without #13): create the planned dose so the answer has a record.
|
||||
row = Dose(token=ctx.token, prescription_id=rx.id, planned_at=ctx.now, status="pending")
|
||||
ctx.session.add(row)
|
||||
await ctx.session.flush()
|
||||
dose_id = row.id
|
||||
text = t.text.format(drug=drug, dose=dose).replace(" ", " ") if drug else t.text_no_drug
|
||||
reply = Reply(text=text, buttons=_reminder_buttons(dose_ref(dose_id, ctx.now.date())), kind="question")
|
||||
return Step(replies=[reply], data={"med_pending": None}) # key of earlier releases: the button carries it now
|
||||
|
||||
|
||||
def _stale_reply() -> Step:
|
||||
return Step(replies=[Reply(text=load_meds_texts().stale)])
|
||||
|
||||
|
||||
async def _legacy(ctx: Ctx, event: Event) -> Step:
|
||||
"""Payload-less medication button of an earlier release: the dose is unknown → treat as stale."""
|
||||
return _stale_reply()
|
||||
|
||||
|
||||
def _with_dose(fn: Any) -> Any:
|
||||
"""Prefix-trigger handler wrapper: resolve the dose payload, answer stale buttons without recording."""
|
||||
|
||||
async def handler(ctx: Ctx, event: Event) -> Step:
|
||||
ref = trigger_payload(event)
|
||||
fresh, _row = await resolve_ref(ctx, ref)
|
||||
if not fresh:
|
||||
return _stale_reply()
|
||||
return await fn(ctx, event, ref)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def med_trigger(name: str) -> Any:
|
||||
"""Register `button:med:<name>:*` (with the dose payload) plus the legacy payload-less key as stale."""
|
||||
|
||||
def deco(fn: Any) -> Any:
|
||||
trigger(f"button:med:{name}:*")(_with_dose(fn))
|
||||
trigger(f"button:med:{name}")(_legacy)
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
@med_trigger("taken")
|
||||
async def _taken(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
await record_dose(ctx, True, ref=ref)
|
||||
return Step(replies=[Reply(text=load_meds_texts().taken_ack)], notify_monitoring=True)
|
||||
|
||||
|
||||
@med_trigger("skip")
|
||||
async def _skip(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
await record_dose(ctx, False, status="skipped", ref=ref)
|
||||
t = load_meds_texts().skip
|
||||
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}:{ref}")] for r in SKIP_REASONS]
|
||||
return Step(replies=[Reply(text=t.question, buttons=buttons, kind="question")], notify_monitoring=True)
|
||||
|
||||
|
||||
def _side_effect_question(ref: str) -> Reply:
|
||||
t = load_meds_texts().side_effects
|
||||
buttons = [[Button(label=t.options[c], data=f"med:se:{c}:{ref}")] for c in SIDE_EFFECT_CODES]
|
||||
return Reply(text=t.question, buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _skip_reason(reason: str) -> Any:
|
||||
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
if reason == "side_effects":
|
||||
return Step(replies=[_side_effect_question(ref)])
|
||||
if reason == "quit":
|
||||
# «Решил бросить» is the same signal as "брошу" in free text: rule `quit_intent` reacts (alert +
|
||||
# delayed-onset explanation through the med_info action).
|
||||
await save_checkin(ctx, quit_intent=True)
|
||||
return Step(replies=[Reply(text=load_meds_texts().skip.ack["quit"])], notify_monitoring=True)
|
||||
return Step(replies=[Reply(text=load_meds_texts().skip.ack[reason])])
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
for _reason in SKIP_REASONS:
|
||||
med_trigger(f"skip_reason:{_reason}")(_skip_reason(_reason))
|
||||
|
||||
|
||||
@med_trigger("side_effects")
|
||||
async def _side_effects(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
return Step(replies=[_side_effect_question(ref)])
|
||||
|
||||
|
||||
async def _after_side_effects(ctx: Ctx, codes: list[str], ref: str | None) -> Step:
|
||||
if ref is None:
|
||||
row = await todays_dose(ctx)
|
||||
ref = dose_ref(row.id if row is not None else None, ctx.now.date())
|
||||
await record_side_effects(ctx, codes, ref=ref)
|
||||
replies = [await side_effect_reply(ctx, codes)]
|
||||
today = await checkin_of(ctx, ctx.now.date())
|
||||
if (today is None or today.med_taken is None) and await current_prescription(ctx) is not None:
|
||||
t = load_meds_texts().side_effects
|
||||
buttons = [
|
||||
[Button(label=t.dose_yes, data=f"med:dose:yes:{ref}")],
|
||||
[Button(label=t.dose_no, data=f"med:dose:no:{ref}")],
|
||||
]
|
||||
replies.append(Reply(text=t.ask_dose, buttons=buttons, kind="question"))
|
||||
return Step(replies=replies, next_state="idle", data={"med_se_ref": None}, notify_monitoring=True)
|
||||
|
||||
|
||||
def _side_effect_code(code: str) -> Any:
|
||||
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
if code == "other":
|
||||
return Step(
|
||||
# kind="question": the engine waits for free text here (clients show an input, optional).
|
||||
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
|
||||
next_state=SIDE_EFFECT_TEXT_STATE,
|
||||
data={"med_se_ref": ref},
|
||||
)
|
||||
return await _after_side_effects(ctx, [code], ref)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
for _code in SIDE_EFFECT_CODES:
|
||||
med_trigger(f"se:{_code}")(_side_effect_code(_code))
|
||||
|
||||
|
||||
@state(SIDE_EFFECT_TEXT_STATE)
|
||||
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
if event.type == "button" and event.button == NO_COMMENT:
|
||||
# The description is optional: «other» side effect without words.
|
||||
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
|
||||
if event.type != "text" or ctx.text is None:
|
||||
return await leave_to_idle(ctx, event, {"med_se_ref": None})
|
||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
|
||||
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
|
||||
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
|
||||
|
||||
|
||||
def _dose_answer(taken: bool) -> Any:
|
||||
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
await record_dose(ctx, taken, status=None if taken else "missed", ref=ref)
|
||||
return Step(
|
||||
replies=[Reply(text=load_meds_texts().taken_ack if taken else load_meds_texts().skip.ack["other"])],
|
||||
notify_monitoring=True,
|
||||
)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
med_trigger("dose:yes")(_dose_answer(True))
|
||||
med_trigger("dose:no")(_dose_answer(False))
|
||||
|
||||
# Free-text waiting states of this flow: a long-forgotten question must not swallow tomorrow's message.
|
||||
abandonable("meds")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- question menu
|
||||
|
||||
|
||||
@trigger("button:menu:med_question", replace=True)
|
||||
async def _menu_question(ctx: Ctx, event: Event) -> Step:
|
||||
t = load_meds_texts().question
|
||||
if await current_prescription(ctx) is None:
|
||||
return Step(replies=[menu_reply(ctx, t.no_prescription)])
|
||||
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@state(QUESTION_STATE)
|
||||
async def _question(ctx: Ctx, event: Event) -> Step:
|
||||
if event.type != "text" or ctx.text is None:
|
||||
return await leave_to_idle(ctx, event)
|
||||
text = await answer_question(ctx, await current_prescription(ctx))
|
||||
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- rule actions
|
||||
|
||||
|
||||
async def med_info_reply(ctx: Ctx, action: Action) -> Reply | None:
|
||||
"""`med_info` action of a rule (quit_intent → onset, alcohol_3d → alcohol): instruction excerpt framed by
|
||||
fixed texts. The rule already decided to say it; this only picks the words. None → engine template."""
|
||||
spec = load_meds_texts().med_info.get(str(action.params.get("topic")))
|
||||
if spec is None:
|
||||
return None
|
||||
rx = await current_prescription(ctx)
|
||||
answer = await med_qa(rx.drug, scrub(spec.question), topic=spec.section) if rx is not None else None
|
||||
body = answer.text if answer is not None else spec.fallback
|
||||
return Reply(text="\n\n".join(p for p in (spec.lead, body, spec.notice) if p), kind="info")
|
||||
|
||||
|
||||
register_med_info_handler(med_info_reply)
|
||||
@@ -0,0 +1,470 @@
|
||||
"""Daily check-in and scheduled-scale intros (#37, ADR-002, CLINICAL_MODULES §1–2, case «мониторинг после
|
||||
обращения»).
|
||||
|
||||
Check-in = core (mood 0–10, dose taken) + the module question (veteran: sleep hours + nightmares; alcohol for
|
||||
veterans and for anyone with a positive AUDIT-C) + free text:
|
||||
|
||||
system:daily_checkin / menu «Как я сегодня»
|
||||
──▶ daily.mood buttons 0–10 (a bare number is accepted too)
|
||||
──▶ daily.med only with a prescription and no dose answer today (the reminder may have got it)
|
||||
──▶ daily.sleep <4 / 4–6 / 6–8 / >8 h ┐ module veteran
|
||||
──▶ daily.nightmares yes / no ┘
|
||||
──▶ daily.alcohol none / 1–2 / more (veteran or AUDIT-C positive)
|
||||
──▶ daily.text «Как вы? Пару слов» (skip button) → extract_checkin on the SCRUBBED text
|
||||
──▶ [daily.alcohol_amount] alcohol mentioned in the text but not asked by button → amount buttons
|
||||
──▶ idle upsert done, `notify_monitoring` → rules (lane B) → dialog actions
|
||||
|
||||
Data algorithm (medical data, jury requirement):
|
||||
* Every button answer is written to `daily_checkins` immediately (`repo.upsert_checkin` merges non-None
|
||||
fields), so an interrupted check-in (crisis, the patient walking away) still leaves today's mood on record —
|
||||
the "mood ≤ 3 three days in a row" rule must not miss a day because the free text was never written.
|
||||
* Button answers are the patient's own and always win over the LLM: from the free text the LLM may only add
|
||||
side effects, the quit intent and fields that were not asked by buttons today.
|
||||
* The LLM never decides anything here: alerts and follow-ups (delayed-onset explanation, unscheduled PHQ-9,
|
||||
alcohol interaction, visit offer) come from the rules through `MonitoringService` (ADR-007).
|
||||
* A free-text message classified POSSIBLE by the safety layer is taken over by the engine (C-SSRS). When the
|
||||
screen returns to `daily.text` the message counts as the answer: the check-in is closed, not asked again.
|
||||
* A check-in left unfinished is dropped on the next event of a later day, or after
|
||||
`settings.abandon_after_hours` of silence (engine abandon timeout, #56) — answers given so far stay stored.
|
||||
|
||||
Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc), registered with
|
||||
`register_scale_intro` so the engine's `scale_due` / rule-triggered sessions show them.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
from app.core.rules import load_rules
|
||||
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,
|
||||
current_prescription,
|
||||
leave_to_idle,
|
||||
load_meds_texts,
|
||||
record_dose,
|
||||
record_side_effects,
|
||||
run_trigger_inline,
|
||||
save_checkin,
|
||||
side_effect_reply,
|
||||
)
|
||||
from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger
|
||||
from app.dialog.scale_session import register_scale_intro
|
||||
|
||||
PREFIX = "daily"
|
||||
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
|
||||
ALCOHOL_AMOUNT = "alcohol_amount"
|
||||
SLEEP_BUCKETS = ("<4", "4-6", "6-8", ">8")
|
||||
ALCOHOL_BUCKETS = ("none", "1-2", "more")
|
||||
_NUMBER = re.compile(r"^\s*(\d{1,2})\s*$")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- texts
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class DailySettings(_Strict):
|
||||
sleep_modules: list[str]
|
||||
alcohol_daily_modules: list[str]
|
||||
intro_scales: list[str]
|
||||
|
||||
|
||||
class Question(_Strict):
|
||||
question: str
|
||||
|
||||
|
||||
class OptionsQuestion(_Strict):
|
||||
question: str
|
||||
options: dict[str, str]
|
||||
|
||||
|
||||
class YesNoQuestion(_Strict):
|
||||
question: str
|
||||
yes: str
|
||||
no: str
|
||||
|
||||
|
||||
class AlcoholQuestion(_Strict):
|
||||
question: str
|
||||
clarify: str
|
||||
options: dict[str, str]
|
||||
|
||||
|
||||
class TextQuestion(_Strict):
|
||||
question: str
|
||||
skip: str
|
||||
|
||||
|
||||
class CheckinTexts(_Strict):
|
||||
start: str
|
||||
mood: Question
|
||||
sleep: OptionsQuestion
|
||||
nightmares: YesNoQuestion
|
||||
alcohol: AlcoholQuestion
|
||||
text: TextQuestion
|
||||
buttons_hint: str
|
||||
done: str
|
||||
already_done: str
|
||||
|
||||
|
||||
class VisitTexts(_Strict):
|
||||
requested: str
|
||||
|
||||
|
||||
class ScaleIntros(_Strict):
|
||||
scheduled: dict[str, str]
|
||||
unscheduled: dict[str, str]
|
||||
|
||||
|
||||
class DailyTexts(_Strict):
|
||||
status: str
|
||||
settings: DailySettings
|
||||
checkin: CheckinTexts
|
||||
visit: VisitTexts
|
||||
scale_intros: ScaleIntros
|
||||
|
||||
|
||||
def daily_texts_path() -> Path:
|
||||
return get_settings().config_dir / "texts" / "veteran_daily.yaml"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_daily_texts(path: Path | None = None) -> DailyTexts:
|
||||
path = path or daily_texts_path()
|
||||
return DailyTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- step logic
|
||||
|
||||
|
||||
def _today(ctx: Ctx) -> str:
|
||||
return ctx.now.date().isoformat()
|
||||
|
||||
|
||||
def _ck(ctx: Ctx) -> dict[str, Any]:
|
||||
return dict(ctx.data.get("checkin") or {})
|
||||
|
||||
|
||||
async def _modules(ctx: Ctx) -> set[str]:
|
||||
profile = await ctx.profile()
|
||||
return set(profile.modules or []) if profile is not None else set()
|
||||
|
||||
|
||||
async def _auditc_positive(ctx: Ctx) -> bool:
|
||||
"""Latest AUDIT-C at or above the sex-specific cut-off of rule `auditc_positive` (one threshold, one place)."""
|
||||
results = await repo.list_scale_results(ctx.session, ctx.token, "auditc")
|
||||
if not results:
|
||||
return False
|
||||
rule = next((r for r in load_rules().rules if r.id == "auditc_positive"), None)
|
||||
if rule is None:
|
||||
return False
|
||||
profile = await ctx.profile()
|
||||
key = "threshold_f" if profile is not None and profile.sex == "f" else "threshold_m"
|
||||
return results[-1].total >= int(rule.params[key])
|
||||
|
||||
|
||||
async def _applicable(ctx: Ctx, step: str) -> bool:
|
||||
settings = load_daily_texts().settings
|
||||
if step in ("mood", "text"):
|
||||
return True
|
||||
if step == "med":
|
||||
today = await checkin_of(ctx, ctx.now.date())
|
||||
return (today is None or today.med_taken is None) and await current_prescription(ctx) is not None
|
||||
modules = await _modules(ctx)
|
||||
if step in ("sleep", "nightmares"):
|
||||
return bool(modules & set(settings.sleep_modules))
|
||||
if step == "alcohol":
|
||||
return bool(modules & set(settings.alcohol_daily_modules)) or await _auditc_positive(ctx)
|
||||
return False
|
||||
|
||||
|
||||
async def _next_step(ctx: Ctx, after: str | None) -> str:
|
||||
rest = STEPS if after is None else STEPS[STEPS.index(after) + 1 :]
|
||||
for step in rest:
|
||||
if await _applicable(ctx, step):
|
||||
return step
|
||||
return "text"
|
||||
|
||||
|
||||
def _buttons(step: str, options: list[tuple[str, str]]) -> list[list[Button]]:
|
||||
return [[Button(label=label, data=f"{PREFIX}:{step}:{value}")] for value, label in options]
|
||||
|
||||
|
||||
async def _question(ctx: Ctx, step: str, note: str = "") -> Reply:
|
||||
t = load_daily_texts().checkin
|
||||
buttons: list[list[Button]]
|
||||
if step == "mood":
|
||||
text = t.mood.question
|
||||
# Two rows (0–5, 6–10): 11 buttons in one column are hard to use on a phone.
|
||||
buttons = [
|
||||
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6)],
|
||||
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6, 11)],
|
||||
]
|
||||
elif step == "med":
|
||||
m = load_meds_texts().checkin
|
||||
rx = await current_prescription(ctx)
|
||||
text = m.question.format(drug=rx.drug) if rx is not None else m.question_no_drug
|
||||
buttons = _buttons(step, [("yes", m.yes), ("no", m.no), ("later", m.later)])
|
||||
elif step == "sleep":
|
||||
text = t.sleep.question
|
||||
buttons = _buttons(step, [(b, t.sleep.options[b]) for b in SLEEP_BUCKETS])
|
||||
elif step == "nightmares":
|
||||
text = t.nightmares.question
|
||||
buttons = _buttons(step, [("yes", t.nightmares.yes), ("no", t.nightmares.no)])
|
||||
elif step in ("alcohol", ALCOHOL_AMOUNT):
|
||||
text = t.alcohol.question if step == "alcohol" else t.alcohol.clarify
|
||||
buttons = _buttons(step, [(b, t.alcohol.options[b]) for b in ALCOHOL_BUCKETS])
|
||||
else:
|
||||
text = t.text.question
|
||||
buttons = _buttons("text", [("skip", t.text.skip)])
|
||||
return Reply(text="\n\n".join(p for p in (note, text) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
async def start_checkin(ctx: Ctx, *, from_menu: bool) -> Step:
|
||||
if ctx.data.get("checkin_done") == _today(ctx):
|
||||
# The scheduler may fire twice; the menu button gets a friendly note instead of a second check-in.
|
||||
return Step(replies=[menu_reply(ctx, load_daily_texts().checkin.already_done)]) if from_menu else Step()
|
||||
first = await _next_step(ctx, None)
|
||||
ck = {"day": _today(ctx), "answered": [], "started_at": ctx.now.isoformat()}
|
||||
return Step(
|
||||
replies=[Reply(text=load_daily_texts().checkin.start)],
|
||||
next_state=f"{PREFIX}.{first}",
|
||||
data={"checkin": ck},
|
||||
)
|
||||
|
||||
|
||||
def _finish(ctx: Ctx, replies: list[Reply] | None = None) -> Step:
|
||||
done = Reply(text=load_daily_texts().checkin.done)
|
||||
return Step(
|
||||
replies=[done, *(replies or [])],
|
||||
next_state="idle",
|
||||
data={"checkin": None, "checkin_done": _today(ctx)},
|
||||
notify_monitoring=True,
|
||||
)
|
||||
|
||||
|
||||
def _consumed_by_safety(ctx: Ctx, ck: dict[str, Any]) -> bool:
|
||||
"""The free-text answer was taken over by the safety layer (C-SSRS screen) after the question was asked."""
|
||||
asked = ck.get("text_asked_at")
|
||||
if not asked:
|
||||
return False
|
||||
for event in reversed(ctx.data.get("safety_events") or []):
|
||||
if event.get("state") == f"{PREFIX}.text" and datetime.fromisoformat(event["ts"]) >= datetime.fromisoformat(
|
||||
asked
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@on_enter(PREFIX)
|
||||
async def _enter(ctx: Ctx) -> Step:
|
||||
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
|
||||
ck = _ck(ctx)
|
||||
if not ck or ck.get("day") != _today(ctx):
|
||||
return Step(next_state="idle", data={"checkin": None})
|
||||
if step == "text":
|
||||
if _consumed_by_safety(ctx, ck):
|
||||
return _finish(ctx)
|
||||
return Step(
|
||||
replies=[await _question(ctx, step)], data={"checkin": {**ck, "text_asked_at": ctx.now.isoformat()}}
|
||||
)
|
||||
return Step(replies=[await _question(ctx, step)])
|
||||
|
||||
|
||||
async def _advance(ctx: Ctx, step: str, ck: dict[str, Any]) -> Step:
|
||||
ck = {**ck, "answered": [*ck.get("answered", []), step]}
|
||||
if step == ALCOHOL_AMOUNT:
|
||||
return _finish(ctx)
|
||||
nxt = await _next_step(ctx, step)
|
||||
return Step(next_state=f"{PREFIX}.{nxt}", data={"checkin": ck})
|
||||
|
||||
|
||||
async def _record_button(ctx: Ctx, step: str, value: str) -> bool:
|
||||
"""Store one button answer right away. False = value not valid for this step (stale/forged button)."""
|
||||
if step == "mood" and value.isdigit() and 0 <= int(value) <= 10:
|
||||
await save_checkin(ctx, mood=int(value))
|
||||
elif step == "med" and value in ("yes", "no", "later"):
|
||||
if value != "later": # «Ещё нет» is not a missed dose: nothing is recorded yet
|
||||
await record_dose(ctx, value == "yes", status=None if value == "yes" else "missed")
|
||||
elif step == "sleep" and value in SLEEP_BUCKETS:
|
||||
await save_checkin(ctx, sleep_bucket=value)
|
||||
elif step == "nightmares" and value in ("yes", "no"):
|
||||
await save_checkin(ctx, nightmares=value == "yes")
|
||||
elif step in ("alcohol", ALCOHOL_AMOUNT) and value in ALCOHOL_BUCKETS:
|
||||
await save_checkin(ctx, alcohol_bucket=value)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _free_text(ctx: Ctx, ck: dict[str, Any]) -> Step:
|
||||
assert ctx.text is not None
|
||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||
answered = set(ck.get("answered", []))
|
||||
fields: dict[str, Any] = {"free_text_scrubbed": ctx.text}
|
||||
replies: list[Reply] = []
|
||||
clarify_alcohol = False
|
||||
if facts is not None:
|
||||
if facts.quit_intent:
|
||||
fields["quit_intent"] = True
|
||||
# Buttons win: the LLM only fills what was not asked today.
|
||||
if "med" not in answered and facts.med_taken is not None:
|
||||
today = await checkin_of(ctx, ctx.now.date())
|
||||
if today is None or today.med_taken is None:
|
||||
fields["med_taken"] = facts.med_taken
|
||||
if "sleep" not in answered and facts.sleep_hours_bucket is not None:
|
||||
fields["sleep_bucket"] = facts.sleep_hours_bucket
|
||||
if "nightmares" not in answered and facts.nightmares is not None:
|
||||
fields["nightmares"] = facts.nightmares
|
||||
if "alcohol" not in answered and facts.alcohol is not None:
|
||||
fields["alcohol_bucket"] = facts.alcohol # provisional; the amount buttons below refine it
|
||||
clarify_alcohol = facts.alcohol != "none"
|
||||
if facts.side_effects:
|
||||
await record_side_effects(ctx, [str(c) for c in facts.side_effects])
|
||||
# One topic per message: when the text also carries a quit intent or alcohol, the rules answer
|
||||
# those (delayed onset / interaction); the side-effect excerpt would only bury that message.
|
||||
if not facts.quit_intent and facts.alcohol in (None, "none"):
|
||||
replies.append(await side_effect_reply(ctx, [str(c) for c in facts.side_effects]))
|
||||
await save_checkin(ctx, **fields)
|
||||
if clarify_alcohol:
|
||||
return Step(replies=replies, next_state=f"{PREFIX}.{ALCOHOL_AMOUNT}", data={"checkin": ck})
|
||||
return _finish(ctx, replies)
|
||||
|
||||
|
||||
abandonable(PREFIX)
|
||||
|
||||
|
||||
@state(PREFIX)
|
||||
async def _handle(ctx: Ctx, event: Event) -> Step:
|
||||
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
|
||||
ck = _ck(ctx)
|
||||
if not ck or ck.get("day") != _today(ctx):
|
||||
# Unfinished check-in from an earlier day: drop it and handle the event as idle would.
|
||||
return await leave_to_idle(ctx, event, {"checkin": None})
|
||||
|
||||
if event.type == "button" and event.button:
|
||||
parts = event.button.split(":")
|
||||
if len(parts) == 3 and parts[0] == PREFIX and parts[1] == step:
|
||||
if step == "text" and parts[2] == "skip":
|
||||
return _finish(ctx)
|
||||
if await _record_button(ctx, step, parts[2]):
|
||||
return await _advance(ctx, step, ck)
|
||||
if parts[0] == "med":
|
||||
# A reminder button pressed mid check-in: answer it, then repeat the current question.
|
||||
inline = await run_trigger_inline(ctx, f"button:{event.button}", event)
|
||||
if inline is not None:
|
||||
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
|
||||
if parts[0] in (PREFIX, "scale"):
|
||||
return Step(replies=[await _question(ctx, step)]) # stale button of an earlier question
|
||||
return await leave_to_idle(ctx, event, {"checkin": None})
|
||||
|
||||
if event.type == "system" and event.system:
|
||||
if event.system == "daily_checkin":
|
||||
return Step(replies=[await _question(ctx, step)])
|
||||
inline = await run_trigger_inline(ctx, f"system:{event.system}", event)
|
||||
if inline is None:
|
||||
return Step()
|
||||
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
|
||||
|
||||
if event.type == "text" and ctx.text is not None:
|
||||
if step == "text":
|
||||
return await _free_text(ctx, ck)
|
||||
number = _NUMBER.match(ctx.text)
|
||||
if step == "mood" and number and await _record_button(ctx, "mood", number.group(1)):
|
||||
return await _advance(ctx, step, ck)
|
||||
return Step(replies=[await _question(ctx, step, note=load_daily_texts().checkin.buttons_hint)])
|
||||
|
||||
return Step(replies=[await _question(ctx, step)])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- idle triggers
|
||||
|
||||
|
||||
@trigger("system:daily_checkin", replace=True)
|
||||
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
|
||||
if intake_enabled(): # the intake diary (#53, feature flag) replaces this check-in when released
|
||||
return await intake.checkin(ctx, event)
|
||||
return await start_checkin(ctx, from_menu=False)
|
||||
|
||||
|
||||
@trigger("button:menu:checkin", replace=True)
|
||||
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
|
||||
if intake_enabled():
|
||||
return await intake.checkin(ctx, event)
|
||||
return await start_checkin(ctx, from_menu=True)
|
||||
|
||||
|
||||
@trigger("button:menu:visit", replace=True)
|
||||
async def _visit(ctx: Ctx, event: Event) -> Step:
|
||||
# «Записаться раньше» (rule action suggest_visit): no online booking yet — the request is a fact for lane B
|
||||
# (#38) and the dashboard, like `contact_requests`. No alert is created here (ADR-007).
|
||||
requests = [*(ctx.data.get("visit_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
|
||||
return Step(
|
||||
replies=[Reply(text=load_daily_texts().visit.requested)],
|
||||
data={"visit_requests": requests},
|
||||
notify_monitoring=True,
|
||||
)
|
||||
|
||||
|
||||
@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); 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:
|
||||
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
|
||||
if facts.alcohol is not None and facts.alcohol != "none":
|
||||
today = await checkin_of(ctx, ctx.now.date())
|
||||
if today is None or today.alcohol_bucket in (None, "none"):
|
||||
fields["alcohol_bucket"] = facts.alcohol
|
||||
text: str | None = None
|
||||
if facts.side_effects:
|
||||
codes = [str(c) for c in facts.side_effects]
|
||||
await record_side_effects(ctx, codes)
|
||||
if not facts.quit_intent:
|
||||
text = (await side_effect_reply(ctx, codes)).text
|
||||
if fields:
|
||||
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))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- scale intros
|
||||
|
||||
|
||||
def _scale_intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
|
||||
"""Friendly intro of a scheduled (or rule-triggered) scale. Onboarding sessions (#36) have their own texts."""
|
||||
if ".onboarding" in return_state:
|
||||
return None
|
||||
intros = load_daily_texts().scale_intros
|
||||
return (intros.scheduled if scheduled else intros.unscheduled).get(scale_id)
|
||||
|
||||
|
||||
for _scale_id in load_daily_texts().settings.intro_scales:
|
||||
register_scale_intro(_scale_id, _scale_intro)
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Veteran start screening (issue #36, CLINICAL_MODULES §1, ADR-001/002/007/010).
|
||||
|
||||
Entered by the core flow after consent + age gate when the profile has module `veteran`:
|
||||
|
||||
veteran.onboarding.start intro «что нормально после возвращения» + FAQ ──«Начнём»──▶
|
||||
veteran.onboarding.exposure trauma exposure (yes / no / decline)
|
||||
yes ──▶ scale.pcptsd5 ──▶ ┐
|
||||
no / decline ───────────▶ ┤ (PC-PTSD-5 is not administered; nothing is stored for it)
|
||||
veteran.onboarding.pcl5 ─┘──▶ scale.pcl5
|
||||
veteran.onboarding.phq9 ──▶ scale.phq9 (item 9 buttons only; positive → engine crisis path)
|
||||
veteran.onboarding.auditc sex from profile, asked if unknown ──▶ scale.auditc
|
||||
veteran.onboarding.weapon weapon access (4 buttons) → facts; access + risk signal → safe-storage block
|
||||
veteran.onboarding.result plain-language summary, modules enabled in profile → finish_onboarding()
|
||||
|
||||
Clinical decisions encoded here:
|
||||
* PCL-5 is given to every veteran regardless of the exposure answer: the module's start set is PCL-5/PHQ-9/
|
||||
AUDIT-C (CLINICAL_MODULES §1); PC-PTSD-5 is only a short entry screen and, by its own instructions, is asked
|
||||
only after a "yes" to trauma exposure (`config/scales/pcptsd5.yaml: notes`).
|
||||
* Module `depression` is enabled when the PHQ-9 total meets `config/modules.yaml:
|
||||
veteran.enables_depression_if` (≥ 10). Scores and severity labels are never shown to the patient.
|
||||
* Safe-storage block (means restriction, one of the best-evidenced suicide-prevention measures) is shown when
|
||||
the patient reports access to a weapon AND there is a risk signal in this onboarding: a scale flag listed in
|
||||
`settings.weapon_risk_flags` (PHQ-9 item 9 ≥ 1, C-SSRS, AUDIT-C positive), a safety event (POSSIBLE/ACUTE)
|
||||
or a crisis opened since the onboarding began. Deterministic, no LLM (ADR-007).
|
||||
|
||||
Facts for lane B (`dialog_state.data["facts"]`, merged with whatever else is there):
|
||||
trauma_exposure True | False | None (declined)
|
||||
weapon_access True (self / others_or_safe) | False (none) | None (declined)
|
||||
weapon_access_choice "none" | "self" | "others_or_safe" | "declined"
|
||||
weapon_access_at ISO timestamp of the answer
|
||||
weapon_safety_block True when the safe-storage block was shown
|
||||
`PatientSnapshot.weapon_access` should be built from `facts["weapon_access"]`.
|
||||
|
||||
Crisis during onboarding: the engine moves to `crisis` and drops the running scale session. Progress is kept in
|
||||
`data["veteran_onboarding"]` (`stage`, `since_id`); when the FSM later enters `idle` (doctor acknowledged the
|
||||
crisis) the onboarding resumes at the stored stage, and scale stages whose result was already stored since the
|
||||
onboarding began are skipped (`since_id` = last scale_results id before the start).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.modules import load_modules
|
||||
from app.db import repo
|
||||
from app.db.clinical import ScaleResult
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open
|
||||
from app.dialog.flows.core import finish_onboarding, menu_reply
|
||||
from app.dialog.fsm import Ctx, Step, on_enter, state, trigger
|
||||
from app.dialog.scale_session import start_scale
|
||||
|
||||
MODULE = "veteran"
|
||||
PREFIX = "veteran.onboarding"
|
||||
START = f"{PREFIX}.start"
|
||||
EXPOSURE = f"{PREFIX}.exposure"
|
||||
PCL5 = f"{PREFIX}.pcl5"
|
||||
PHQ9 = f"{PREFIX}.phq9"
|
||||
AUDITC = f"{PREFIX}.auditc"
|
||||
WEAPON = f"{PREFIX}.weapon"
|
||||
RESULT = f"{PREFIX}.result"
|
||||
|
||||
BTN = "vonb" # button data namespace: "vonb:<question>:<choice>"
|
||||
FAQ_KEYS = ("who", "unit", "licences")
|
||||
|
||||
# choice code → value of facts["weapon_access"]: storage with others still means the patient owns a weapon.
|
||||
WEAPON_ACCESS: dict[str, bool | None] = {"none": False, "self": True, "others_or_safe": True, "declined": None}
|
||||
EXPOSURE_VALUE: dict[str, bool | None] = {"yes": True, "no": False, "declined": None}
|
||||
SEX_VALUE: dict[str, str | None] = {"m": "m", "f": "f", "declined": None}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- texts
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class OnbSettings(_Strict):
|
||||
weapon_risk_flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class IntroTexts(_Strict):
|
||||
text: str
|
||||
go: str
|
||||
repeat: str
|
||||
resume: str
|
||||
|
||||
|
||||
class ExposureTexts(_Strict):
|
||||
question: str
|
||||
answer_yes: str
|
||||
answer_no: str
|
||||
decline: str
|
||||
ack_yes: str
|
||||
ack_other: str
|
||||
|
||||
|
||||
class SexTexts(_Strict):
|
||||
question: str
|
||||
male: str
|
||||
female: str
|
||||
decline: str
|
||||
|
||||
|
||||
class WeaponTexts(_Strict):
|
||||
question: str
|
||||
no_access: str
|
||||
self: str
|
||||
others_or_safe: str
|
||||
decline: str
|
||||
ack: str
|
||||
safety_block_self: str
|
||||
safety_block_stored: str
|
||||
safety_block_unknown: str
|
||||
|
||||
|
||||
class ResultTexts(_Strict):
|
||||
intro: str
|
||||
calm: str
|
||||
load: str
|
||||
ptsd: str
|
||||
mood: str
|
||||
alcohol: str
|
||||
next: str
|
||||
|
||||
|
||||
class FaqEntry(_Strict):
|
||||
button: str
|
||||
text: str
|
||||
|
||||
|
||||
class FaqTexts(_Strict):
|
||||
who: FaqEntry
|
||||
unit: FaqEntry
|
||||
licences: FaqEntry
|
||||
|
||||
|
||||
class VeteranOnboardingTexts(_Strict):
|
||||
status: str
|
||||
settings: OnbSettings
|
||||
intro: IntroTexts
|
||||
buttons_hint: str
|
||||
exposure: ExposureTexts
|
||||
sex: SexTexts
|
||||
weapon: WeaponTexts
|
||||
result: ResultTexts
|
||||
faq: FaqTexts
|
||||
|
||||
|
||||
def texts_path() -> Path:
|
||||
return get_settings().config_dir / "texts" / "veteran_onboarding.yaml"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_texts(path: Path | None = None) -> VeteranOnboardingTexts:
|
||||
path = path or texts_path()
|
||||
return VeteranOnboardingTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _progress(ctx: Ctx) -> dict[str, Any]:
|
||||
return dict(ctx.data.get("veteran_onboarding") or {})
|
||||
|
||||
|
||||
def _at_stage(ctx: Ctx, stage: str) -> dict[str, Any]:
|
||||
"""Data update remembering the current stage (resume point after a crisis)."""
|
||||
return {"veteran_onboarding": {**_progress(ctx), "stage": stage}}
|
||||
|
||||
|
||||
def _facts(ctx: Ctx, **updates: Any) -> dict[str, Any]:
|
||||
return {"facts": {**(ctx.data.get("facts") or {}), **updates}}
|
||||
|
||||
|
||||
def _faq_buttons() -> list[list[Button]]:
|
||||
faq = load_texts().faq
|
||||
return [[Button(label=getattr(faq, key).button, data=f"{BTN}:faq:{key}")] for key in FAQ_KEYS]
|
||||
|
||||
|
||||
def _choice(event: Event, question: str, allowed: dict[str, Any]) -> str | None:
|
||||
if event.type != "button" or not event.button:
|
||||
return None
|
||||
parts = event.button.split(":")
|
||||
if len(parts) == 3 and parts[0] == BTN and parts[1] == question and parts[2] in allowed:
|
||||
return parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def _faq_key(event: Event) -> str | None:
|
||||
if event.type == "button" and event.button and event.button.startswith(f"{BTN}:faq:"):
|
||||
key = event.button.rsplit(":", 1)[1]
|
||||
return key if key in FAQ_KEYS else None
|
||||
return None
|
||||
|
||||
|
||||
async def _results_since(ctx: Ctx) -> list[ScaleResult]:
|
||||
since_id = int(_progress(ctx).get("since_id") or 0)
|
||||
return [r for r in await repo.list_scale_results(ctx.session, ctx.token) if r.id > since_id]
|
||||
|
||||
|
||||
async def _latest(ctx: Ctx, scale: str) -> ScaleResult | None:
|
||||
rows = [r for r in await _results_since(ctx) if r.scale == scale]
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
async def _risk_signals(ctx: Ctx) -> list[str]:
|
||||
"""Deterministic risk signals collected during this onboarding (see module docstring)."""
|
||||
risk_flags = set(load_texts().settings.weapon_risk_flags)
|
||||
signals = sorted({f for r in await _results_since(ctx) for f in (r.flags or {}) if f in risk_flags})
|
||||
started = str(_progress(ctx).get("started_at") or "")
|
||||
events = [
|
||||
e
|
||||
for e in ctx.data.get("safety_events") or []
|
||||
if str(e.get("ts", "")) >= started and e.get("level") in ("possible", "acute")
|
||||
]
|
||||
if events:
|
||||
signals.append("safety_event")
|
||||
opened_at = str((ctx.data.get("crisis") or {}).get("opened_at") or "")
|
||||
if crisis_open(ctx.data) or (opened_at and opened_at >= started):
|
||||
signals.append("crisis")
|
||||
return signals
|
||||
|
||||
|
||||
async def _scale_stage(ctx: Ctx, stage: str, scale: str, next_stage: str) -> Step:
|
||||
"""Start `scale` unless its result is already stored in this onboarding (resume after a crisis)."""
|
||||
if await _latest(ctx, scale) is not None:
|
||||
return Step(next_state=next_stage, data=_at_stage(ctx, next_stage))
|
||||
step = await start_scale(ctx, scale, return_state=next_stage, scheduled=True)
|
||||
step.data.update(_at_stage(ctx, stage))
|
||||
return step
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- start
|
||||
|
||||
|
||||
def _intro_reply(text: str) -> Reply:
|
||||
go = Button(label=load_texts().intro.go, data=f"{BTN}:go:yes")
|
||||
return Reply(text=text, buttons=[[go], *_faq_buttons()], kind="question")
|
||||
|
||||
|
||||
@on_enter(START)
|
||||
async def _start_enter(ctx: Ctx) -> Step:
|
||||
rows = await repo.list_scale_results(ctx.session, ctx.token)
|
||||
progress = {
|
||||
"stage": START,
|
||||
"since_id": max((r.id for r in rows), default=0),
|
||||
"started_at": ctx.now.isoformat(),
|
||||
}
|
||||
return Step(replies=[_intro_reply(load_texts().intro.text)], data={"veteran_onboarding": progress})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- questions
|
||||
|
||||
|
||||
def _exposure_reply(note: str = "") -> Reply:
|
||||
t = load_texts().exposure
|
||||
buttons = [
|
||||
[Button(label=t.answer_yes, data=f"{BTN}:exposure:yes")],
|
||||
[Button(label=t.answer_no, data=f"{BTN}:exposure:no")],
|
||||
[Button(label=t.decline, data=f"{BTN}:exposure:declined")],
|
||||
]
|
||||
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _sex_reply(note: str = "") -> Reply:
|
||||
t = load_texts().sex
|
||||
buttons = [
|
||||
[Button(label=t.male, data=f"{BTN}:sex:m")],
|
||||
[Button(label=t.female, data=f"{BTN}:sex:f")],
|
||||
[Button(label=t.decline, data=f"{BTN}:sex:declined")],
|
||||
]
|
||||
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _weapon_reply(note: str = "") -> Reply:
|
||||
t = load_texts().weapon
|
||||
buttons = [
|
||||
[Button(label=t.no_access, data=f"{BTN}:weapon:none")],
|
||||
[Button(label=t.self, data=f"{BTN}:weapon:self")],
|
||||
[Button(label=t.others_or_safe, data=f"{BTN}:weapon:others_or_safe")],
|
||||
[Button(label=t.decline, data=f"{BTN}:weapon:declined")],
|
||||
]
|
||||
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
|
||||
|
||||
|
||||
def _current_question(ctx: Ctx, note: str = "") -> Reply:
|
||||
if ctx.state == EXPOSURE:
|
||||
return _exposure_reply(note)
|
||||
if ctx.state == AUDITC:
|
||||
return _sex_reply(note)
|
||||
if ctx.state == WEAPON:
|
||||
return _weapon_reply(note)
|
||||
t = load_texts().intro
|
||||
return _intro_reply("\n\n".join(p for p in (note, t.repeat) if p))
|
||||
|
||||
|
||||
@on_enter(EXPOSURE)
|
||||
async def _exposure_enter(ctx: Ctx) -> Step:
|
||||
return Step(replies=[_exposure_reply()], data=_at_stage(ctx, EXPOSURE))
|
||||
|
||||
|
||||
@on_enter(PCL5)
|
||||
async def _pcl5_enter(ctx: Ctx) -> Step:
|
||||
return await _scale_stage(ctx, PCL5, "pcl5", PHQ9)
|
||||
|
||||
|
||||
@on_enter(PHQ9)
|
||||
async def _phq9_enter(ctx: Ctx) -> Step:
|
||||
# Item 9 is buttons-only and a positive answer opens the crisis path inside the scale session (engine).
|
||||
return await _scale_stage(ctx, PHQ9, "phq9", AUDITC)
|
||||
|
||||
|
||||
@on_enter(AUDITC)
|
||||
async def _auditc_enter(ctx: Ctx) -> Step:
|
||||
if await _latest(ctx, "auditc") is not None:
|
||||
return Step(next_state=WEAPON, data=_at_stage(ctx, WEAPON))
|
||||
profile = await ctx.profile()
|
||||
known = profile is not None and profile.sex in ("m", "f")
|
||||
if known or _progress(ctx).get("sex_asked"):
|
||||
# Unknown sex after "не хочу отвечать": AUDIT-C uses the more sensitive default threshold.
|
||||
return await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
|
||||
return Step(replies=[_sex_reply()], data=_at_stage(ctx, AUDITC))
|
||||
|
||||
|
||||
@on_enter(WEAPON)
|
||||
async def _weapon_enter(ctx: Ctx) -> Step:
|
||||
return Step(replies=[_weapon_reply()], data=_at_stage(ctx, WEAPON))
|
||||
|
||||
|
||||
@state(PREFIX)
|
||||
async def _handle(ctx: Ctx, event: Event) -> Step:
|
||||
"""Events in the onboarding's own question states (scales are handled by the scale session)."""
|
||||
t = load_texts()
|
||||
faq = _faq_key(event)
|
||||
if faq is not None:
|
||||
return Step(replies=[Reply(text=getattr(t.faq, faq).text), _current_question(ctx)])
|
||||
if event.type == "system":
|
||||
return Step() # reminders do not interrupt the screening
|
||||
|
||||
if ctx.state == START:
|
||||
if _choice(event, "go", {"yes": True}):
|
||||
return Step(next_state=EXPOSURE)
|
||||
return Step(replies=[_current_question(ctx)])
|
||||
|
||||
if ctx.state == EXPOSURE and (code := _choice(event, "exposure", EXPOSURE_VALUE)):
|
||||
exposed = EXPOSURE_VALUE[code]
|
||||
step = Step(data=_facts(ctx, trauma_exposure=exposed))
|
||||
if exposed:
|
||||
step.replies.append(Reply(text=t.exposure.ack_yes))
|
||||
nxt = await start_scale(ctx, "pcptsd5", return_state=PCL5, scheduled=True)
|
||||
nxt.data.update(_at_stage(ctx, EXPOSURE))
|
||||
return step.then(nxt)
|
||||
step.replies.append(Reply(text=t.exposure.ack_other))
|
||||
return step.then(Step(next_state=PCL5))
|
||||
|
||||
if ctx.state == AUDITC and (code := _choice(event, "sex", SEX_VALUE)):
|
||||
sex = SEX_VALUE[code]
|
||||
if sex is not None:
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, sex=sex)
|
||||
step = await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
|
||||
step.data["veteran_onboarding"] = {**step.data["veteran_onboarding"], "sex_asked": True}
|
||||
return step
|
||||
|
||||
if ctx.state == WEAPON and (code := _choice(event, "weapon", WEAPON_ACCESS)):
|
||||
return await _weapon_answer(ctx, code)
|
||||
|
||||
return Step(replies=[_current_question(ctx, t.buttons_hint)])
|
||||
|
||||
|
||||
async def _weapon_answer(ctx: Ctx, code: str) -> Step:
|
||||
t = load_texts().weapon
|
||||
access = WEAPON_ACCESS[code]
|
||||
replies = [Reply(text=t.ack)]
|
||||
show_block = access is not False and bool(await _risk_signals(ctx))
|
||||
if show_block:
|
||||
block = {"self": t.safety_block_self, "others_or_safe": t.safety_block_stored}.get(code, t.safety_block_unknown)
|
||||
replies = [Reply(text=block, kind="safety_plan")]
|
||||
data = _facts(
|
||||
ctx,
|
||||
weapon_access=access,
|
||||
weapon_access_choice=code,
|
||||
weapon_access_at=ctx.now.isoformat(),
|
||||
weapon_safety_block=show_block,
|
||||
)
|
||||
# notify_monitoring: weapon access is a monitoring fact (lane B rule "доступ к оружию → блок безопасности").
|
||||
return Step(replies=replies, next_state=RESULT, data=data, notify_monitoring=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- result
|
||||
|
||||
|
||||
def _meets(total: int, op: str, value: int) -> bool:
|
||||
return total >= value if op == ">=" else total > value
|
||||
|
||||
|
||||
@on_enter(RESULT)
|
||||
async def _result_enter(ctx: Ctx) -> Step:
|
||||
t = load_texts().result
|
||||
pcl5 = await _latest(ctx, "pcl5")
|
||||
auditc = await _latest(ctx, "auditc")
|
||||
|
||||
# Depression module: PHQ-9 total vs. the threshold from config/modules.yaml (veteran.enables_depression_if).
|
||||
rule = load_modules().modules[MODULE].enables_depression_if
|
||||
trigger_result = await _latest(ctx, rule.scale) if rule is not None else None
|
||||
depression = bool(rule and trigger_result and _meets(trigger_result.total, rule.op, rule.value))
|
||||
profile = await ctx.profile()
|
||||
modules = list(profile.modules or []) if profile is not None else []
|
||||
for module in (MODULE, "depression") if depression else (MODULE,):
|
||||
if module not in modules:
|
||||
modules.append(module)
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, modules=modules)
|
||||
|
||||
lines = []
|
||||
if pcl5 is not None and "pcl5_probable_ptsd" in (pcl5.flags or {}):
|
||||
lines.append(t.ptsd)
|
||||
if depression:
|
||||
lines.append(t.mood)
|
||||
if auditc is not None and "auditc_positive" in (auditc.flags or {}):
|
||||
lines.append(t.alcohol)
|
||||
body = [t.intro, t.load, *lines] if lines else [t.intro, t.calm]
|
||||
summary = Reply(text="\n".join([*body, "", t.next]), buttons=_faq_buttons())
|
||||
done = Step(
|
||||
replies=[summary],
|
||||
data={"veteran_onboarding": {**_progress(ctx), "stage": None, "done_at": ctx.now.isoformat()}},
|
||||
notify_monitoring=True,
|
||||
)
|
||||
return done.then(await finish_onboarding(ctx, module=MODULE))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------- resume / FAQ
|
||||
|
||||
|
||||
@on_enter("idle")
|
||||
async def _resume_after_crisis(ctx: Ctx) -> Step:
|
||||
"""Back in idle with an unfinished veteran onboarding (it was interrupted by a crisis) → continue it."""
|
||||
stage = _progress(ctx).get("stage")
|
||||
if not stage or not str(stage).startswith(PREFIX) or crisis_open(ctx.data) or ctx.data.get("consent") is not True:
|
||||
return Step()
|
||||
stage = EXPOSURE if stage == START else str(stage)
|
||||
return Step(replies=[Reply(text=load_texts().intro.resume)], next_state=stage)
|
||||
|
||||
|
||||
def _faq_trigger(key: str) -> None:
|
||||
@trigger(f"button:{BTN}:faq:{key}")
|
||||
async def _faq(ctx: Ctx, event: Event) -> Step:
|
||||
return Step(replies=[menu_reply(ctx, getattr(load_texts().faq, key).text)])
|
||||
|
||||
|
||||
for _key in FAQ_KEYS:
|
||||
_faq_trigger(_key)
|
||||
+57
-4
@@ -22,6 +22,14 @@ Name resolution: an exact registration wins, otherwise the longest registered do
|
||||
`@trigger("system:daily_checkin")`, `@trigger("text:idle")`) let flows take over what the core `idle` state does
|
||||
with menu buttons, system events and free text; the core registers defaults, a flow overrides with
|
||||
`replace=True`.
|
||||
|
||||
Prefix triggers carry data in the button (#56): `@trigger("button:med:taken:*")` handles every button whose data
|
||||
starts with "med:taken:"; the rest of the data ("med:taken:42" → "42") reaches the handler as
|
||||
`trigger_payload(event)`. An exact key always wins over a prefix, a longer prefix over a shorter one.
|
||||
|
||||
Abandoned steps (#56): a flow marks its question states with `abandonable("daily")`; if the patient stays silent
|
||||
in such a state longer than `settings.abandon_after_hours`, the engine returns the FSM to `idle` before
|
||||
handling the next event (answers already stored stay stored).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -103,6 +111,11 @@ EnterHook = Callable[[Ctx], Awaitable[Step]]
|
||||
_handlers: dict[str, Handler] = {}
|
||||
_enter_hooks: dict[str, EnterHook] = {}
|
||||
_triggers: dict[str, Handler] = {}
|
||||
_prefix_triggers: dict[str, Handler] = {} # "button:med:taken:" → handler (registered as "button:med:taken:*")
|
||||
_abandonable: dict[str, Callable[[Ctx], bool] | None] = {}
|
||||
|
||||
PREFIX_WILDCARD = "*"
|
||||
PAYLOAD_PARAM = "trigger_payload"
|
||||
|
||||
|
||||
def state(name: str) -> Callable[[Handler], Handler]:
|
||||
@@ -133,18 +146,45 @@ def trigger(key: str, *, replace: bool = False) -> Callable[[Handler], Handler]:
|
||||
"""Register a handler for an idle-menu button / system event / idle free text.
|
||||
|
||||
Keys: "button:<Button.data>", "system:<SystemEventKind>", "text:idle". The core registers defaults;
|
||||
flows pass `replace=True` to take one over.
|
||||
flows pass `replace=True` to take one over. A key ending in ":*" is a prefix trigger (see module doc).
|
||||
"""
|
||||
table, name = _triggers, key
|
||||
if key.endswith(":" + PREFIX_WILDCARD):
|
||||
table, name = _prefix_triggers, key[: -len(PREFIX_WILDCARD)]
|
||||
|
||||
def deco(fn: Handler) -> Handler:
|
||||
if key in _triggers and not replace:
|
||||
if name in table and not replace:
|
||||
raise ValueError(f"trigger '{key}' already registered (use replace=True to override)")
|
||||
_triggers[key] = fn
|
||||
table[name] = fn
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def trigger_payload(event: Event) -> str | None:
|
||||
"""Data after the matched prefix of a prefix trigger ("med:taken:42" → "42"); None for exact triggers."""
|
||||
value = event.params.get(PAYLOAD_PARAM)
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def abandonable(prefix: str, when: Callable[[Ctx], bool] | None = None) -> None:
|
||||
"""Mark states `prefix` / `prefix.*` as question steps the engine may abandon after a long silence.
|
||||
|
||||
`when(ctx)` may veto it for a particular situation (e.g. a scale that is part of an onboarding).
|
||||
"""
|
||||
if prefix in _abandonable:
|
||||
raise ValueError(f"abandonable '{prefix}' already registered")
|
||||
_abandonable[prefix] = when
|
||||
|
||||
|
||||
def is_abandonable(ctx: Ctx) -> bool:
|
||||
key = _resolve(ctx.state, _abandonable)
|
||||
if key is None:
|
||||
return False
|
||||
when = _abandonable[key]
|
||||
return when is None or when(ctx)
|
||||
|
||||
|
||||
def _resolve(name: str, table: dict[str, Any]) -> str | None:
|
||||
candidate = name
|
||||
while candidate:
|
||||
@@ -167,7 +207,20 @@ def enter_hook_for(name: str) -> EnterHook | None:
|
||||
|
||||
|
||||
def get_trigger(key: str) -> Handler | None:
|
||||
return _triggers.get(key)
|
||||
"""Exact trigger, else the longest matching prefix trigger (wrapped so it sees the payload)."""
|
||||
exact = _triggers.get(key)
|
||||
if exact is not None:
|
||||
return exact
|
||||
matches = [p for p in _prefix_triggers if key.startswith(p) and len(key) > len(p)]
|
||||
if not matches:
|
||||
return None
|
||||
prefix = max(matches, key=len)
|
||||
fn, payload = _prefix_triggers[prefix], key[len(prefix) :]
|
||||
|
||||
async def with_payload(ctx: Ctx, event: Event) -> Step:
|
||||
return await fn(ctx, event.model_copy(update={"params": {**event.params, PAYLOAD_PARAM: payload}}))
|
||||
|
||||
return with_payload
|
||||
|
||||
|
||||
def is_registered(name: str) -> bool:
|
||||
|
||||
@@ -15,13 +15,22 @@ Algorithm per item:
|
||||
4. Every answer is stored immediately (`item_responses`), so a session interrupted by a crisis keeps its data.
|
||||
5. After each answer the scale is scored deterministically (`app.core.scales.score`). If a crisis flag from
|
||||
`config/texts/dialog.yaml: settings.crisis_flags` fires (PHQ-9 item 9 ≥ 1, C-SSRS high) the session ends at
|
||||
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS). A positive
|
||||
item 9 is acted upon even in a half-finished questionnaire.
|
||||
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS, or a C-SSRS was
|
||||
completed less than `settings.cssrs_cooldown_hours` ago — the patient is not asked the same suicide screen
|
||||
twice in a day; the crisis script and the alert still happen). A positive item 9 is acted upon even in a
|
||||
half-finished questionnaire.
|
||||
6. On completion: `scale_results` row (total, severity, item 9, scheduled flag, flags) → the engine commits
|
||||
and calls `MonitoringService.after_data_change()` (rules decide alerts — ADR-007) → back to `return_state`.
|
||||
The patient never sees a score or a severity label: that would read as a diagnosis.
|
||||
|
||||
Intros (#56): `start_scale(intro=None)` looks the intro up in a registry filled by flows
|
||||
(`register_scale_intro("phq9", fn_or_text)`), so scales started by the engine (`scale_due`, rule actions) get
|
||||
the flow's friendly intro without the flow hooking `scale.<id>` itself.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from app.core.privacy import scrub
|
||||
@@ -29,10 +38,28 @@ from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
|
||||
from app.db import repo
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
from app.dialog.fsm import Ctx, Step, on_enter, state
|
||||
from app.dialog.fsm import RESTING_STATES, Ctx, Step, abandonable, is_abandonable, on_enter, state
|
||||
|
||||
PREFIX = "scale"
|
||||
|
||||
# (ctx, scale_id, scheduled, return_state) → intro text or None (no intro in this situation).
|
||||
IntroFn = Callable[[Ctx, str, bool, str], str | None]
|
||||
_intros: dict[str, str | IntroFn] = {}
|
||||
|
||||
|
||||
def register_scale_intro(scale_id: str, intro: str | IntroFn, *, replace: bool = False) -> None:
|
||||
"""Register the intro shown before `scale_id` when a session is started without an explicit `intro`."""
|
||||
if scale_id in _intros and not replace:
|
||||
raise ValueError(f"intro of scale '{scale_id}' already registered (use replace=True to override)")
|
||||
_intros[scale_id] = intro
|
||||
|
||||
|
||||
def scale_intro(ctx: Ctx, scale_id: str, *, scheduled: bool, return_state: str) -> str | None:
|
||||
intro = _intros.get(scale_id)
|
||||
if intro is None or isinstance(intro, str):
|
||||
return intro or None
|
||||
return intro(ctx, scale_id, scheduled, return_state) or None
|
||||
|
||||
|
||||
def scale_state(scale_id: str) -> str:
|
||||
return f"{PREFIX}.{scale_id}"
|
||||
@@ -56,9 +83,11 @@ async def start_scale(
|
||||
"""Begin a session of `scale_id`; returns a Step moving the FSM to `scale.<id>`.
|
||||
|
||||
`scheduled=False` marks an unscheduled administration (triggered by a rule or a safety signal).
|
||||
`intro` — optional extra text shown before the scale instruction.
|
||||
`intro` — optional extra text shown before the scale instruction; None → the registered intro, if any.
|
||||
"""
|
||||
scale = load_scale(scale_id) # fail fast on an unknown id
|
||||
if intro is None:
|
||||
intro = scale_intro(ctx, scale_id, scheduled=scheduled, return_state=return_state)
|
||||
sessions = dict(ctx.data.get("scales") or {})
|
||||
sessions[scale_id] = {
|
||||
"idx": 0,
|
||||
@@ -98,6 +127,28 @@ def _session(ctx: Ctx) -> tuple[ScaleDef, dict[str, Any]] | None:
|
||||
return load_scale(scale_id), session
|
||||
|
||||
|
||||
def _abandon_allowed(ctx: Ctx) -> bool:
|
||||
"""A silent scale session may be abandoned only if what it returns to may be (idle, a check-in step) —
|
||||
never an onboarding chain, which would otherwise be skipped."""
|
||||
found = _session(ctx)
|
||||
if found is None:
|
||||
return True
|
||||
back = str(found[1].get("return_state") or "idle")
|
||||
if back in RESTING_STATES:
|
||||
return True
|
||||
return back != ctx.state and is_abandonable(replace(ctx, state=back))
|
||||
|
||||
|
||||
abandonable(PREFIX, _abandon_allowed)
|
||||
|
||||
|
||||
async def recent_risk_screen(ctx: Ctx, screen: str) -> bool:
|
||||
"""A complete risk screen (C-SSRS) less than `cssrs_cooldown_hours` ago (scale_results, any trigger)."""
|
||||
since = ctx.now - timedelta(hours=ctx.texts.settings.cssrs_cooldown_hours)
|
||||
results = await repo.list_scale_results(ctx.session, ctx.token, screen)
|
||||
return any(r.complete and r.ts >= since for r in results)
|
||||
|
||||
|
||||
@on_enter(PREFIX)
|
||||
async def _enter(ctx: Ctx) -> Step:
|
||||
found = _session(ctx)
|
||||
@@ -231,6 +282,7 @@ async def _finish(
|
||||
else:
|
||||
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
|
||||
step = done.then(crisis)
|
||||
if scale.id != risk_screen:
|
||||
# Same-day repeat guard: a C-SSRS completed within the cooldown already answered the screening question.
|
||||
if scale.id != risk_screen and not await recent_risk_screen(ctx, risk_screen):
|
||||
step.data["pending_scales"] = add_pending(ctx.data, risk_screen, scheduled=False)
|
||||
return step
|
||||
|
||||
@@ -19,6 +19,7 @@ class DialogSettings(_Strict):
|
||||
max_safety_events: int = 50
|
||||
crisis_flags: list[str] = Field(default_factory=list)
|
||||
risk_screen_scale: str = "cssrs_screen"
|
||||
abandon_after_hours: float = 6
|
||||
|
||||
|
||||
class Commands(_Strict):
|
||||
@@ -46,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,35 @@
|
||||
"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
|
||||
|
||||
Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
|
||||
code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
|
||||
class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
|
||||
|
||||
It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
|
||||
(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
|
||||
production provider for all channels (#14).
|
||||
|
||||
This module may use the identity DB: it is the registration boundary (guardrail #5).
|
||||
"""
|
||||
|
||||
from app.core import clock
|
||||
from app.db import identity_repo
|
||||
from app.db.identity import ConnectCode
|
||||
from app.db.session import get_identity_session
|
||||
from app.integrations.base import VerifiedPerson
|
||||
|
||||
CODE_MAX_LEN = 32
|
||||
|
||||
|
||||
class EmiasMock:
|
||||
async def verify(self, auth_code: str) -> VerifiedPerson | None:
|
||||
code = auth_code.strip().upper()
|
||||
if not code or len(code) > CODE_MAX_LEN:
|
||||
return None
|
||||
async with get_identity_session() as s:
|
||||
row = await s.get(ConnectCode, code)
|
||||
if row is None:
|
||||
return None
|
||||
token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
|
||||
if token is None:
|
||||
return None
|
||||
return VerifiedPerson(token=token, doctor_id=row.doctor_id)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Knowledge base (RAG, #60): clinical guidelines -> citable chunks -> BM25 retrieval -> grounded answers.
|
||||
|
||||
from app.llm.rag import answer, retrieve
|
||||
hits = retrieve("синдром отмены антидепрессантов", audience="doctor")
|
||||
reply = await answer(scrub(text), audience="patient") # None -> rag.fallback_text()
|
||||
|
||||
Corpus and parameters: `config/rag/sources.yaml`; PDF sources and the converter: `docs/source/rag/`.
|
||||
"""
|
||||
|
||||
from app.llm.rag.corpus import Chunk, load_chunks
|
||||
from app.llm.rag.index import Hit, get_index, retrieve
|
||||
from app.llm.rag.qa import Citation, RagAnswer, answer, fallback_text
|
||||
|
||||
__all__ = [
|
||||
"Chunk",
|
||||
"Citation",
|
||||
"Hit",
|
||||
"RagAnswer",
|
||||
"answer",
|
||||
"fallback_text",
|
||||
"get_index",
|
||||
"load_chunks",
|
||||
"retrieve",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Manual check of the knowledge base on synthetic questions.
|
||||
|
||||
python -m app.llm.rag "через сколько начинают действовать антидепрессанты"
|
||||
python -m app.llm.rag --audience doctor "синдром отмены антидепрессантов"
|
||||
python -m app.llm.rag --search "ДПДГ при ПТСР" # raw retrieval: chunks, scores, passages
|
||||
python -m app.llm.rag --stats # corpus summary
|
||||
|
||||
The question always goes through `privacy.scrub()` first; the CLI never bypasses it.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
|
||||
from app.core import privacy
|
||||
from app.llm.gateway import get_gateway
|
||||
from app.llm.rag.index import get_index
|
||||
from app.llm.rag.qa import answer, fallback_text
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.llm.rag")
|
||||
parser.add_argument("question", nargs="?")
|
||||
parser.add_argument("--audience", choices=["patient", "doctor", "team"], default="patient")
|
||||
parser.add_argument("--search", action="store_true", help="show retrieved chunks instead of an answer")
|
||||
parser.add_argument("--top", type=int, default=5)
|
||||
parser.add_argument("--stats", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
index = get_index()
|
||||
if args.stats:
|
||||
by_doc = Counter(c.doc.short for c in index.chunks)
|
||||
print(f"chunks={len(index.chunks)}", ", ".join(f"{d}: {n}" for d, n in by_doc.items()))
|
||||
return 0
|
||||
if not args.question:
|
||||
parser.error("question is required")
|
||||
question = privacy.scrub(args.question)
|
||||
|
||||
if args.search:
|
||||
for hit in index.search(question, audience=args.audience, top_k=args.top, min_score=0):
|
||||
print(f"{hit.score:6.1f} (bm25 {hit.bm25:5.1f}) {hit.chunk.citation} {hit.chunk.section}")
|
||||
print(f" > {hit.passage[:300]}")
|
||||
return 0
|
||||
|
||||
print(f"provider={get_gateway().provider_name} audience={args.audience}\n")
|
||||
reply = asyncio.run(answer(question, audience=args.audience, index=index))
|
||||
print(reply.text if reply else fallback_text(args.audience))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Knowledge-base corpus: `config/rag/sources.yaml` + txt files -> citable chunks.
|
||||
|
||||
The txt files come from `docs/source/rag/pdf_to_txt.py`: paragraphs are separated by blank lines, pages by
|
||||
`--- стр. N ---` markers, tables are markdown, text transcribed from figures follows an
|
||||
`[Изображение: ...]` line. Chunking rules:
|
||||
|
||||
* a chunk never crosses a section boundary (numbered headings "2.1 ...", "Приложение Г5", ...), so its
|
||||
section title is reliable context; the title is stored separately and indexed with extra weight;
|
||||
* "Комментарии: ..." 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]"; inline
|
||||
"⟦стр. N⟧" markers (paragraph glued across a page break) are removed from the text but move the range.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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 ..."
|
||||
# Top-level KR sections: "1. Краткая информация ...", "3. Лечение, включая ...". Plain "1. ..." lines are
|
||||
# list items, so a top-level heading must start with one of the standard KR section names.
|
||||
TOPLEVEL = re.compile(
|
||||
r"^([1-9])\.\s+(Краткая информация|Диагностика|Лечение|Медицинская реабилитация|Профилактика"
|
||||
r"|Организация оказания|Дополнительная информация)"
|
||||
)
|
||||
NAMED = re.compile(
|
||||
r"^(Приложение [А-ЯЁ]\d*(?:-[А-ЯЁ]+\d*)?\b.*|Критерии оценки качества медицинской помощи|Список литературы"
|
||||
r"|Список сокращений|Термины и определения)$"
|
||||
)
|
||||
# Unnumbered short title lines (team specs): capitalised, short, no final punctuation, not a list item.
|
||||
SHORT_TITLE = re.compile(r"^[А-ЯЁ][^.:;!?|]{2,60}$")
|
||||
COMMENT = re.compile(r"^Комментари[йи]\s*:")
|
||||
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[А-ЯЁA-Z0-9«\"(])")
|
||||
# Abbreviations after which a capital letter does not start a new sentence: "(см. Приложение Б5)".
|
||||
ABBREVIATION_END = re.compile(
|
||||
r"(?:\b(?:см|т\.\s?е|т\.\s?д|т\.\s?п|др|напр|рис|табл|стр|г|гг|им|ср|англ|лат|греч)|\b[А-ЯЁA-Z])\.$"
|
||||
)
|
||||
MAX_TITLE_CHARS = 300
|
||||
|
||||
|
||||
class DocumentMeta(BaseModel):
|
||||
id: str
|
||||
file: str
|
||||
title: str
|
||||
short: str
|
||||
issuer: str
|
||||
year: int
|
||||
icd: list[str] = Field(default_factory=list)
|
||||
audiences: list[Audience]
|
||||
|
||||
|
||||
class ChunkingConfig(BaseModel):
|
||||
max_chars: int = 1200
|
||||
min_chars: int = 200
|
||||
skip_sections: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RetrievalConfig(BaseModel):
|
||||
top_k: int = 5
|
||||
bm25_k1: float = 1.5
|
||||
bm25_b: float = 0.75
|
||||
title_weight: int = 2
|
||||
min_score: float = 3.0
|
||||
rerank_candidates: int = 30
|
||||
window_sentences: int = 2
|
||||
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):
|
||||
max_chunks: int = 3
|
||||
max_passages: int = 2
|
||||
max_sentences: int = 3
|
||||
max_chars: int = 600
|
||||
min_coverage: float = 0.5
|
||||
second_passage_ratio: float = 0.8
|
||||
neighbor_ratio: float = 0.5
|
||||
|
||||
|
||||
class RagConfig(BaseModel):
|
||||
documents: list[DocumentMeta]
|
||||
chunking: ChunkingConfig = ChunkingConfig()
|
||||
retrieval: RetrievalConfig = RetrievalConfig()
|
||||
answer: AnswerConfig = AnswerConfig()
|
||||
synonyms: list[list[str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Chunk:
|
||||
id: str # "<doc>#<n>"
|
||||
doc: DocumentMeta
|
||||
section: str # nearest heading ("" before the first one)
|
||||
page_start: int
|
||||
page_end: int
|
||||
text: str # display text: paragraphs and markdown table rows
|
||||
|
||||
@property
|
||||
def pages(self) -> str:
|
||||
return str(self.page_start) if self.page_start == self.page_end else f"{self.page_start}–{self.page_end}"
|
||||
|
||||
@property
|
||||
def citation(self) -> str:
|
||||
return f"[{self.doc.short}, стр. {self.pages}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Unit:
|
||||
"""Indivisible piece of a chunk: a paragraph (with its comment) or one table row."""
|
||||
|
||||
text: str
|
||||
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
|
||||
class _Section:
|
||||
title: str
|
||||
units: list[_Unit] = field(default_factory=list)
|
||||
|
||||
|
||||
def rag_dir() -> Path:
|
||||
return get_settings().config_dir / "rag"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_config(path: Path | None = None) -> RagConfig:
|
||||
path = path or rag_dir() / "sources.yaml"
|
||||
return RagConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def heading(line: str) -> str | None:
|
||||
"""The line's text if it is a section heading, else `None`."""
|
||||
if len(line) > MAX_TITLE_CHARS or line.startswith("|"):
|
||||
return None
|
||||
if SUBSECTION.match(line) or TOPLEVEL.match(line) or NAMED.match(line):
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def _is_short_title(block: list[str], next_block: list[str] | None) -> bool:
|
||||
"""A lone short line followed by a body paragraph (unnumbered headings in team docs)."""
|
||||
return (
|
||||
len(block) == 1
|
||||
and bool(SHORT_TITLE.match(block[0]))
|
||||
and next_block is not None
|
||||
and not PAGE_MARKER.match(next_block[0])
|
||||
and (len(next_block) > 1 or len(next_block[0]) > 80 or next_block[0].startswith("|"))
|
||||
)
|
||||
|
||||
|
||||
def _blocks(text: str) -> list[list[str]]:
|
||||
"""Blank-line separated blocks of non-empty lines."""
|
||||
return [[ln for ln in b.splitlines() if ln.strip()] for b in re.split(r"\n\s*\n", text) if b.strip()]
|
||||
|
||||
|
||||
def _sections(text: str, skip: list[str]) -> list[_Section]:
|
||||
blocks = _blocks(text)
|
||||
sections = [_Section(title="")]
|
||||
page = 1
|
||||
skipping = False
|
||||
for i, block in enumerate(blocks):
|
||||
first = block[0]
|
||||
if first.startswith("# ") or first.startswith("Источник:"):
|
||||
continue # file header written by the converter
|
||||
m = PAGE_MARKER.match(first)
|
||||
if m:
|
||||
page = int(m.group(1))
|
||||
continue
|
||||
nxt = next((b for b in blocks[i + 1 :] if not PAGE_MARKER.match(b[0])), None)
|
||||
if first.startswith("|"):
|
||||
if not skipping:
|
||||
sections[-1].units.extend(_table_units(block, page))
|
||||
continue
|
||||
if first.startswith("[Изображение"):
|
||||
if not skipping: # transcribed figure: one unit, keeps its own line breaks
|
||||
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)
|
||||
if title is None and j == 0 and _is_short_title(block, nxt):
|
||||
title = line
|
||||
if title is not None:
|
||||
skipping = any(title.startswith(s) for s in skip)
|
||||
sections.append(_Section(title=title))
|
||||
continue
|
||||
if skipping:
|
||||
continue
|
||||
units = sections[-1].units
|
||||
if COMMENT.match(line) and units and units[-1].table_header is None:
|
||||
units[-1].append(line, page, "\n")
|
||||
elif line[0].islower() and units and units[-1].table_header is None:
|
||||
units[-1].append(line, page, " ") # paragraph continued after a page break or a figure
|
||||
else:
|
||||
units.append(_new_unit(line, page))
|
||||
return [s for s in sections if s.units]
|
||||
|
||||
|
||||
def _table_units(block: list[str], page: int) -> list[_Unit]:
|
||||
if len(block) >= 2 and TABLE_DIVIDER.match(block[1]):
|
||||
header = f"{block[0]}\n{block[1]}"
|
||||
rows = block[2:]
|
||||
else: # continuation without a header line (should not happen with the converter, but be safe)
|
||||
header, rows = "", block
|
||||
return [_Unit(row, page, table_header=header) for row in rows]
|
||||
|
||||
|
||||
def split_sentences(text: str) -> list[str]:
|
||||
"""Sentences of one line/paragraph; does not split after "см.", "т.е.", initials."""
|
||||
out: list[str] = []
|
||||
for part in SENTENCE_SPLIT.split(text):
|
||||
if out and ABBREVIATION_END.search(out[-1]):
|
||||
out[-1] += " " + part
|
||||
else:
|
||||
out.append(part)
|
||||
return [p.strip() for p in out if p.strip()]
|
||||
|
||||
|
||||
def _split_long(unit: _Unit, max_chars: int) -> list[_Unit]:
|
||||
if len(unit.text) <= max_chars or unit.table_header is not None:
|
||||
return [unit]
|
||||
parts: list[str] = []
|
||||
for sentence in split_sentences(unit.text):
|
||||
if parts and len(parts[-1]) + 1 + len(sentence) <= max_chars:
|
||||
parts[-1] += " " + sentence
|
||||
else:
|
||||
parts.append(sentence)
|
||||
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:
|
||||
out: list[str] = []
|
||||
header: str | None = None
|
||||
for u in units:
|
||||
if u.table_header is not None:
|
||||
if header != u.table_header:
|
||||
header = u.table_header
|
||||
if header:
|
||||
out.append(header)
|
||||
out.append(u.text)
|
||||
else:
|
||||
header = None
|
||||
out.append(u.text)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _pack(section: _Section, max_chars: int, min_chars: int) -> list[list[_Unit]]:
|
||||
units = [p for u in section.units for p in _split_long(u, max_chars)]
|
||||
groups: list[list[_Unit]] = [[]]
|
||||
size = 0
|
||||
for u in units:
|
||||
if groups[-1] and size + len(u.text) > max_chars:
|
||||
groups.append([])
|
||||
size = 0
|
||||
groups[-1].append(u)
|
||||
size += len(u.text) + 1
|
||||
if len(groups) > 1 and sum(len(u.text) for u in groups[-1]) < min_chars:
|
||||
groups[-2].extend(groups.pop())
|
||||
return [g for g in groups if g]
|
||||
|
||||
|
||||
def chunk_document(doc: DocumentMeta, text: str, cfg: ChunkingConfig) -> list[Chunk]:
|
||||
chunks: list[Chunk] = []
|
||||
for section in _sections(text, cfg.skip_sections):
|
||||
for group in _pack(section, cfg.max_chars, cfg.min_chars):
|
||||
chunks.append(
|
||||
Chunk(
|
||||
id=f"{doc.id}#{len(chunks)}",
|
||||
doc=doc,
|
||||
section=section.title,
|
||||
page_start=group[0].page,
|
||||
page_end=max(u.page_end for u in group),
|
||||
text=_render(group),
|
||||
)
|
||||
)
|
||||
return chunks
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_chunks(config_path: Path | None = None) -> tuple[Chunk, ...]:
|
||||
cfg = load_config(config_path)
|
||||
base = (config_path.parent if config_path else rag_dir()).resolve()
|
||||
out: list[Chunk] = []
|
||||
for doc in cfg.documents:
|
||||
out.extend(chunk_document(doc, (base / doc.file).read_text(encoding="utf-8"), cfg.chunking))
|
||||
return tuple(out)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Lexical retrieval over the corpus: Okapi BM25 on Russian Snowball stems + synonym expansion.
|
||||
|
||||
Offline and deterministic (ADR-004): no embeddings, no network. Stop words are dropped before stemming;
|
||||
the section title is indexed `title_weight` times so a match with "3.2 Психофармакотерапия" counts more
|
||||
than a passing mention in the body.
|
||||
|
||||
Re-ranking: the best `rerank_candidates` chunks by BM25 get a bonus for their densest passage — a window
|
||||
of `window_sentences` consecutive sentences scored by the idf of query terms it contains. A chunk that
|
||||
answers in one place beats a long one that mentions the words in passing; the passage itself is returned
|
||||
with the hit and is what `answer()` quotes from.
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
from app.llm.rag.corpus import Audience, Chunk, RagConfig, load_chunks, load_config, split_sentences
|
||||
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(
|
||||
"""
|
||||
а без более бы был была были было быть в вам вас ваш весь во вот все всего всех вы где да даже для до его
|
||||
ее если есть еще же за и из или им их к как ко когда кто ли либо мне может можно мой мы на над не него нее
|
||||
нет ни них но ну о об однако он она они оно от по под при про с со так также такой там те то тоже только
|
||||
том тот у уже хотя чего чем что чтобы эта эти это этот я ли ней нем
|
||||
какой какая какое какие каков такое такая такие сколько почему зачем меня мой моя мое мои себя сегодня будет
|
||||
""".split() # noqa: SIM905 — a word list reads better as text
|
||||
)
|
||||
|
||||
|
||||
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(_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
|
||||
out.extend(stem(p) for p in parts)
|
||||
return out
|
||||
|
||||
|
||||
EXPANSION_WEIGHT = 0.7
|
||||
SYNONYM_CREDIT = 0.8
|
||||
PREFIX_LEN = 6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hit:
|
||||
chunk: Chunk
|
||||
score: float # BM25 + passage bonus (ranking key)
|
||||
bm25: float
|
||||
passage: str # densest window of the chunk for the query
|
||||
|
||||
|
||||
def sentences(text: str) -> list[str]:
|
||||
"""Sentences of a chunk; every line (table row, list item) is split on its own."""
|
||||
return [s for line in text.splitlines() for s in split_sentences(line)]
|
||||
|
||||
|
||||
class SynonymExpander:
|
||||
"""Adds the stems of every phrase in a synonym group when any phrase of the group is in the query.
|
||||
|
||||
Added stems weigh `EXPANSION_WEIGHT`: they widen recall but should not outrank the patient's own words.
|
||||
"""
|
||||
|
||||
def __init__(self, groups: list[list[str]]) -> None:
|
||||
self._groups = [[tokens(phrase) for phrase in group] for group in groups]
|
||||
# stem -> stems of the other phrases of its groups ("отменя" -> {"отмен", "брос", ...})
|
||||
self.related: dict[str, set[str]] = {}
|
||||
for group in self._groups:
|
||||
stems = {t for phrase in group for t in phrase}
|
||||
for t in stems:
|
||||
self.related.setdefault(t, set()).update(stems - {t})
|
||||
|
||||
def expand(self, query_tokens: list[str]) -> dict[str, float]:
|
||||
weights: dict[str, float] = {}
|
||||
for t in query_tokens:
|
||||
weights[t] = weights.get(t, 0.0) + 1.0
|
||||
present = set(query_tokens)
|
||||
for group in self._groups:
|
||||
if any(phrase and set(phrase) <= present for phrase in group):
|
||||
for phrase in group:
|
||||
for t in phrase:
|
||||
if t not in present: # a 3-word expansion of "птср" is one concept, not three
|
||||
weights[t] = max(weights.get(t, 0.0), EXPANSION_WEIGHT / len(phrase))
|
||||
return weights
|
||||
|
||||
|
||||
class BM25Index:
|
||||
def __init__(self, chunks: tuple[Chunk, ...], cfg: RagConfig) -> None:
|
||||
r = cfg.retrieval
|
||||
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)
|
||||
self._tf: list[Counter[str]] = []
|
||||
df: Counter[str] = Counter()
|
||||
for c in chunks:
|
||||
tf = Counter(tokens(c.text) + tokens(c.section) * r.title_weight)
|
||||
self._tf.append(tf)
|
||||
df.update(tf.keys())
|
||||
self._len = [sum(tf.values()) for tf in self._tf]
|
||||
self._avg_len = sum(self._len) / max(len(self._len), 1)
|
||||
n = len(chunks)
|
||||
self._idf = {t: math.log(1 + (n - f + 0.5) / (f + 0.5)) for t, f in df.items()}
|
||||
|
||||
def query_terms(self, query: str) -> dict[str, float]:
|
||||
"""Query stems -> weight (repeated words count more, synonym expansions less)."""
|
||||
return self._expander.expand(tokens(query))
|
||||
|
||||
def _score(self, i: int, terms: dict[str, float]) -> float:
|
||||
tf, norm = self._tf[i], self._k1 * (1 - self._b + self._b * self._len[i] / self._avg_len)
|
||||
score = 0.0
|
||||
for term, weight in terms.items():
|
||||
f = tf.get(term)
|
||||
if f:
|
||||
score += self._idf[term] * f * (self._k1 + 1) / (f + norm) * weight
|
||||
return score
|
||||
|
||||
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 "", {})
|
||||
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()`)."""
|
||||
present = set(tokens(text))
|
||||
return sum(self._idf.get(t, 0.0) * w for t, w in terms.items() if t in present)
|
||||
|
||||
def coverage(self, text: str, query: str) -> float:
|
||||
"""Share of the query's own (not synonym-expanded) idf mass found in `text`, 0..1.
|
||||
|
||||
A query word whose synonym — or a word with the same `PREFIX_LEN`-letter prefix, for stems Snowball
|
||||
leaves apart ("скрининговый"/"скрининг") — is in the text counts as `SYNONYM_CREDIT` of itself.
|
||||
A word absent from the corpus counts with twice the maximal idf — the strongest sign of an off-topic
|
||||
question: "как установить windows" is then mostly uncovered and `answer()` says there is
|
||||
no answer instead of quoting the nearest paragraph about "установить диагноз".
|
||||
"""
|
||||
own = set(tokens(query))
|
||||
if not own:
|
||||
return 0.0
|
||||
unseen = 2 * max(self._idf.values(), default=1.0)
|
||||
total = sum(self._idf.get(t, unseen) for t in own)
|
||||
present = set(tokens(text))
|
||||
prefixes = {p[:PREFIX_LEN] for p in present if len(p) >= PREFIX_LEN}
|
||||
covered = 0.0
|
||||
for t in own:
|
||||
if t in present:
|
||||
covered += self._idf[t]
|
||||
elif self._expander.related.get(t, set()) & present or (
|
||||
len(t) >= PREFIX_LEN and t[:PREFIX_LEN] in prefixes
|
||||
):
|
||||
covered += SYNONYM_CREDIT * self._idf.get(t, unseen) # said in the corpus' own words
|
||||
return min(1.0, covered / total)
|
||||
|
||||
def _passage(self, i: int, terms: dict[str, float]) -> tuple[float, str]:
|
||||
sents = self._sentences[i]
|
||||
best = (0.0, sents[0][0] if sents else "")
|
||||
for start in range(len(sents)):
|
||||
window = sents[start : start + self._window]
|
||||
present = set().union(*(toks for _, toks in window))
|
||||
score = sum(self._idf.get(t, 0.0) * w for t, w in terms.items() if t in present)
|
||||
if score > best[0]:
|
||||
best = (score, " ".join(text for text, _ in window))
|
||||
return best
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
audience: Audience | None = None,
|
||||
docs: set[str] | None = None,
|
||||
top_k: int | None = None,
|
||||
min_score: float | None = None,
|
||||
) -> list[Hit]:
|
||||
"""Best chunks for `query`, filtered by audience/doc ids; empty if nothing passes `min_score` (BM25)."""
|
||||
terms = self.query_terms(query)
|
||||
if not terms:
|
||||
return []
|
||||
threshold = self._min_score if min_score is None else min_score
|
||||
scored = [
|
||||
(s, i)
|
||||
for i, c in enumerate(self.chunks)
|
||||
if (audience is None or audience in c.doc.audiences)
|
||||
and (docs is None or c.doc.id in docs)
|
||||
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, cued), bm25, passage)
|
||||
)
|
||||
hits.sort(key=lambda h: -h.score) # stable: BM25 order breaks ties
|
||||
return hits[: top_k or self._top_k]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_index() -> BM25Index:
|
||||
return BM25Index(load_chunks(), load_config())
|
||||
|
||||
|
||||
def retrieve(query: str, **kwargs) -> list[Hit]:
|
||||
"""Module-level shortcut: `retrieve("когда начинает действовать антидепрессант", audience="patient")`."""
|
||||
return get_index().search(query, **kwargs)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Grounded answers from the knowledge base: every sentence shown is quoted from a cited chunk.
|
||||
|
||||
Pipeline (deterministic except for the optional patient rephrasing):
|
||||
|
||||
1. **Dose guard (patient).** Dose questions are never answered — the dose is the doctor's decision.
|
||||
2. **Retrieve.** Top `answer.max_chunks` chunks for the audience (`team` documents never reach patients
|
||||
or doctors, see `config/rag/sources.yaml`).
|
||||
3. **Sentences.** Chunks are split into sentences; KR boilerplate is stripped (literature refs "[1, 15]").
|
||||
Doctors also get table rows as "column: value; ..." lines and evidence grades. For patients, sentences
|
||||
about prescribing/dosing or with forbidden content (`QUOTE_FILTER`) are dropped.
|
||||
4. **Passage.** In each chunk the best sentence (idf-weighted overlap with the query) is extended with
|
||||
adjacent relevant sentences into one contiguous passage — the answer reads as written in the source,
|
||||
not as a collage. The best passage, plus a second one if it scores close, is kept.
|
||||
5. **No-answer gate.** If the passages cover less than `answer.min_coverage` of the question's idf mass
|
||||
(e.g. "рецепт борща"), there is no answer -> `None`.
|
||||
6. **Rephrase (patient only).** The excerpt may be simplified via `gateway.phrase("rag_answer")`; a
|
||||
rephrasing that adds content (`med_qa.adds_content`) is rejected and the excerpt is shown verbatim.
|
||||
Doctors always get the verbatim text.
|
||||
7. **Framing.** Intro, citations "[КР ПТСР 2023, стр. 28]" and the doctor note from `config/texts/rag.yaml`
|
||||
are added outside the LLM, so they are always present.
|
||||
|
||||
Safety screening of the incoming message is the caller's job (guardrail #2): `answer()` expects text that
|
||||
already passed `app.core.safety` and `privacy.scrub()`. Logs carry only codes and counts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.types import ScrubbedText
|
||||
from app.llm.gateway import FORBIDDEN_OUTPUT, MAX_REPLY_CHARS, get_gateway
|
||||
from app.llm.med_qa import DOSE_QUESTION, DOSING_SENTENCE, adds_content
|
||||
from app.llm.rag.corpus import TABLE_DIVIDER, AnswerConfig, Audience, Chunk, load_config, split_sentences
|
||||
from app.llm.rag.index import BM25Index, get_index, tokens
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PHRASE_INTENT = "rag_answer"
|
||||
|
||||
LIT_REFS = re.compile(r"\s*\[\d+(?:\s*[-–,]\s*\d+)*\s*,?\]") # "[1, 15, 16]", "[48]", "[1-3]"
|
||||
GRADE = re.compile(r"\s*Уровень убедительности рекомендаций\s+[ABCАВС]\b.*$", re.IGNORECASE)
|
||||
BULLET = re.compile(r"^\s*(?:[-–—●•▪◦*]|\d+[.)])\s+")
|
||||
COMMENT_LABEL = re.compile(r"^Комментари[йи]\s*[:.]\s*")
|
||||
# KR sentences about prescribing are for the doctor: a patient reading "рекомендуется назначение
|
||||
# пароксетина" or "дозировка титруется" out of context takes it as advice (SPEC §7: no drug/dose advice).
|
||||
PRESCRIBING = re.compile(
|
||||
r"назнач|дозиров|титр|мг\b|мг/|доз[аеуыо]\b|дозы\b|/сут|в сутки|раза? в день|разово"
|
||||
r"|\d+\s*(?:[-–]\s*\d+\s*)?таблет",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# "что такое флэшбек" -> 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)
|
||||
# The gateway's output filter targets the bot's own words. Two of its patterns also hit neutral quotes from
|
||||
# the KR — the definition "ПТСР – это расстройство, которое..." and "диагноз ставится на основании..." —
|
||||
# and would cut exactly the patient-information text. Quotes are checked against the rest of the filter
|
||||
# (dose/drug imperatives, "у вас депрессия", confidentiality promises).
|
||||
QUOTE_EXEMPT_PREFIXES = ("диагноз", "(это|похоже на|признаки)")
|
||||
QUOTE_FILTER = tuple(p for p in FORBIDDEN_OUTPUT if not p.pattern.startswith(QUOTE_EXEMPT_PREFIXES))
|
||||
MIN_SENTENCE_CHARS = 25
|
||||
LEAD_IN_CHARS = 80
|
||||
# A best sentence opening with a reference back ("Это помогает...") needs the sentence it refers to.
|
||||
ANAPHORA = re.compile(
|
||||
r"^(Это|Эти|Этот|Эта|Он|Она|Оно|Они|Такие|Такой|Данн\w+|При этом|Кроме того|Однако|Поэтому)\b"
|
||||
) # a best sentence this short ("Что такое ПТСР?") pulls in the next one as its answer
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
doc: str # document id from sources.yaml
|
||||
short: str # "КР ПТСР 2023"
|
||||
title: str
|
||||
section: str
|
||||
pages: str # "28" | "28–29"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return f"{self.short}, стр. {self.pages}"
|
||||
|
||||
|
||||
class RagAnswer(BaseModel):
|
||||
text: str # framed answer: intro + body + sources (+ doctor note for patients)
|
||||
excerpt: str # the quoted passages, verbatim
|
||||
citations: list[Citation]
|
||||
audience: Audience
|
||||
coverage: float # share of the question covered by the passages (0..1)
|
||||
phrased_by_llm: bool
|
||||
|
||||
|
||||
class AudienceTexts(BaseModel):
|
||||
fallback: str
|
||||
intro: str
|
||||
sources_label: str
|
||||
doctor_note: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Passage:
|
||||
chunk: Chunk
|
||||
text: str
|
||||
score: float
|
||||
definition: bool = False # starts with the term asked in "что такое X?"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_rag_texts(path: Path | None = None) -> dict[str, AudienceTexts]:
|
||||
path = path or get_settings().config_dir / "texts" / "rag.yaml"
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return {k: AudienceTexts.model_validate(v) for k, v in raw.items()}
|
||||
|
||||
|
||||
def fallback_text(audience: Audience = "patient") -> str:
|
||||
"""What the caller sends when `answer()` returns `None`."""
|
||||
return load_rag_texts()[audience].fallback
|
||||
|
||||
|
||||
def _clean(sentence: str, *, keep_grade: bool) -> str:
|
||||
s = BULLET.sub("", sentence)
|
||||
s = COMMENT_LABEL.sub("", s)
|
||||
if not keep_grade: # evidence grades matter to doctors, to a patient they are noise
|
||||
s = GRADE.sub("", s)
|
||||
s = LIT_REFS.sub("", s)
|
||||
return " ".join(s.split()).strip(" ;")
|
||||
|
||||
|
||||
def _cells(line: str) -> list[str]:
|
||||
return [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def _row_text(header: list[str], row: list[str]) -> str:
|
||||
pairs = [f"{h}: {v}" if h else v for h, v in zip(header, row, strict=False) if v]
|
||||
return LIT_REFS.sub("", "; ".join(pairs))
|
||||
|
||||
|
||||
def chunk_sentences(chunk: Chunk, *, clinical: bool) -> list[str | None]:
|
||||
"""Readable sentences of a chunk in reading order. `clinical` (doctor/team) keeps table rows as
|
||||
"column: value" lines and evidence grades. `None` marks a dropped row: passages do not span it."""
|
||||
out: list[str | None] = []
|
||||
lines = chunk.text.splitlines()
|
||||
header: list[str] | None = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("|"):
|
||||
if TABLE_DIVIDER.match(line):
|
||||
continue
|
||||
if i + 1 < len(lines) and TABLE_DIVIDER.match(lines[i + 1]):
|
||||
header = _cells(line)
|
||||
continue
|
||||
out.append(_row_text(header or [], _cells(line)) if clinical else None)
|
||||
continue
|
||||
header = None
|
||||
out.extend(s for s in (_clean(p, keep_grade=clinical) for p in split_sentences(line)) if s)
|
||||
return out
|
||||
|
||||
|
||||
def _allowed(sentence: str | None, audience: Audience) -> bool:
|
||||
if sentence is None or len(sentence) < MIN_SENTENCE_CHARS and not sentence.endswith(("?", ":")):
|
||||
return False
|
||||
if sentence[0].islower():
|
||||
return False # tail of a paragraph cut by the chunk boundary: unreadable without its start
|
||||
if audience == "patient":
|
||||
return not (
|
||||
DOSING_SENTENCE.search(sentence)
|
||||
or PRESCRIBING.search(sentence)
|
||||
or any(p.search(sentence) for p in QUOTE_FILTER)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _defined_term(question: str) -> set[str] | None:
|
||||
m = DEFINITION_QUESTION.match(LEADING_PLACEHOLDERS.sub("", question))
|
||||
return set(tokens(m.group(1))) or None if m else None
|
||||
|
||||
|
||||
def _is_definition(sentence: str, defined: set[str]) -> bool:
|
||||
""" "Флэшбек (flashback – англ.) – возврат..." / "ПТСР – это расстройство...": the term, then a dash."""
|
||||
head = DEFINITION_HEAD.split(sentence, maxsplit=1)
|
||||
return len(head) == 2 and len(head[0]) <= DEFINITION_HEAD_CHARS and bool(set(tokens(head[0])) & defined)
|
||||
|
||||
|
||||
def _sentence_score(index: BM25Index, sentence: str, terms: dict[str, float], defined: set[str] | None) -> float:
|
||||
score = index.overlap(sentence, terms)
|
||||
if defined and score > 0 and _is_definition(sentence, defined):
|
||||
score *= DEFINITION_BONUS
|
||||
return score
|
||||
|
||||
|
||||
def best_passage(
|
||||
index: BM25Index,
|
||||
chunk: Chunk,
|
||||
terms: dict[str, float],
|
||||
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")]
|
||||
scores = [_sentence_score(index, s, terms, defined) if s else 0.0 for s in sents]
|
||||
if not scores or max(scores) <= 0:
|
||||
return None
|
||||
best = max(range(len(sents)), key=lambda i: (scores[i], -i))
|
||||
head = sents[best]
|
||||
assert head is not None # score > 0 implies an allowed sentence
|
||||
lo = hi = best
|
||||
size = len(head)
|
||||
|
||||
def fits(j: int) -> bool:
|
||||
if not 0 <= j < len(sents):
|
||||
return False
|
||||
s = sents[j]
|
||||
return s is not None and size + len(s) + 1 <= cfg.max_chars
|
||||
|
||||
# A short lead-in (question-like heading, "Комментарии:"-style intro) needs the sentence after it.
|
||||
def length(j: int) -> int:
|
||||
return len(sents[j] or "") + 1
|
||||
|
||||
if (size < LEAD_IN_CHARS or head.endswith(("?", ":"))) and fits(best + 1):
|
||||
hi += 1
|
||||
size += length(hi)
|
||||
if ANAPHORA.match(head) and fits(best - 1):
|
||||
lo -= 1
|
||||
size += length(lo)
|
||||
floor = cfg.neighbor_ratio * scores[best]
|
||||
while hi - lo + 1 < cfg.max_sentences:
|
||||
nxt = next((j for j in (hi + 1, lo - 1) if fits(j) and scores[j] > 0 and scores[j] >= floor), None)
|
||||
if nxt is None:
|
||||
break
|
||||
lo, hi = min(lo, nxt), max(hi, nxt)
|
||||
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, cued), definition)
|
||||
|
||||
|
||||
def select(index: BM25Index, question: str, audience: Audience, docs: set[str] | None = None) -> list[Passage]:
|
||||
"""Best passage, plus a second one from another chunk if it scores within `second_passage_ratio`."""
|
||||
cfg = load_config().answer
|
||||
hits = index.search(question, audience=audience, docs=docs, top_k=cfg.max_chunks)
|
||||
terms = index.query_terms(question)
|
||||
defined = _defined_term(question)
|
||||
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.
|
||||
passages.sort(key=lambda p: (not p.definition, -p.score)) # stable: retrieval rank breaks ties
|
||||
top = passages[0]
|
||||
chosen = [top]
|
||||
for p in passages[1:]:
|
||||
if len(chosen) >= cfg.max_passages:
|
||||
break
|
||||
if p.score >= cfg.second_passage_ratio * top.score and p.text != top.text:
|
||||
chosen.append(p)
|
||||
return chosen
|
||||
|
||||
|
||||
def _citation(chunk: Chunk) -> Citation:
|
||||
return Citation(
|
||||
doc=chunk.doc.id, short=chunk.doc.short, title=chunk.doc.title, section=chunk.section, pages=chunk.pages
|
||||
)
|
||||
|
||||
|
||||
async def _rephrase(excerpt: str, question: str) -> tuple[str, bool]:
|
||||
if len(excerpt) > MAX_REPLY_CHARS or any(p.search(excerpt) for p in FORBIDDEN_OUTPUT):
|
||||
return excerpt, False # the gateway would reject any reply this long / with these words anyway
|
||||
text = await get_gateway().phrase(PHRASE_INTENT, {"excerpt": excerpt, "question": question})
|
||||
if " ".join(text.split()) == " ".join(excerpt.split()):
|
||||
return excerpt, False
|
||||
if adds_content(text, excerpt, question):
|
||||
log.warning("rag rephrasing rejected: adds content")
|
||||
return excerpt, False
|
||||
return text, True
|
||||
|
||||
|
||||
async def answer(
|
||||
question: ScrubbedText,
|
||||
*,
|
||||
audience: Audience = "patient",
|
||||
docs: set[str] | None = None,
|
||||
index: BM25Index | None = None,
|
||||
) -> RagAnswer | None:
|
||||
"""Answer strictly from the knowledge base, with citations; `None` -> caller sends `fallback_text()`."""
|
||||
if audience == "patient" and DOSE_QUESTION.search(question.lower()):
|
||||
log.info("rag: dose question redirected to doctor")
|
||||
return None
|
||||
index = index or get_index()
|
||||
passages = select(index, question, audience, docs)
|
||||
if not passages:
|
||||
log.info("rag: no relevant fragment audience=%s", audience)
|
||||
return None
|
||||
coverage = index.coverage(" ".join(p.text for p in passages), question)
|
||||
if coverage < load_config().answer.min_coverage:
|
||||
log.info("rag: low coverage audience=%s coverage=%.2f", audience, coverage)
|
||||
return None
|
||||
citations = [_citation(p.chunk) for p in passages]
|
||||
texts = load_rag_texts()[audience]
|
||||
|
||||
if audience == "patient":
|
||||
excerpt = " ".join(p.text for p in passages)
|
||||
body, by_llm = await _rephrase(excerpt, question)
|
||||
sources = "; ".join(dict.fromkeys(c.label for c in citations))
|
||||
text = f"{texts.intro} {body}\n\n{texts.sources_label}: {sources}."
|
||||
else: # doctor/team: verbatim quotes, each with its own citation
|
||||
excerpt = "\n".join(f"{p.text} [{c.label}]" for p, c in zip(passages, citations, strict=True))
|
||||
by_llm = False
|
||||
text = f"{texts.intro}\n{excerpt}"
|
||||
if texts.doctor_note:
|
||||
text += f"\n\n{texts.doctor_note}"
|
||||
log.info("rag: answered audience=%s passages=%d coverage=%.2f llm=%s", audience, len(passages), coverage, by_llm)
|
||||
return RagAnswer(
|
||||
text=text, excerpt=excerpt, citations=citations, audience=audience, coverage=coverage, phrased_by_llm=by_llm
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Russian Snowball stemmer (https://snowballstem.org/algorithms/russian/stemmer.html).
|
||||
|
||||
Self-contained because `pyproject.toml` is a frozen contract (#34): no new dependencies for the RAG.
|
||||
Latin words and digits pass through lowercased; "ё" is folded to "е".
|
||||
"""
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
_VOWELS = set("аеиоуыэюя")
|
||||
|
||||
# (suffixes, needs_a_ya): group-1 suffixes count only when preceded by "а"/"я" (which stays in the stem).
|
||||
_PERFECTIVE_GERUND = ((("вшись", "вши", "в"), True), (("ившись", "ывшись", "ивши", "ывши", "ив", "ыв"), False))
|
||||
_REFLEXIVE = ((("ся", "сь"), False),)
|
||||
_ADJECTIVE = (
|
||||
(
|
||||
(
|
||||
"ими", "ыми", "его", "ого", "ему", "ому", "ее", "ие", "ые", "ое", "ей", "ий", "ый", "ой", "ем",
|
||||
"им", "ым", "ом", "их", "ых", "ую", "юю", "ая", "яя", "ою", "ею",
|
||||
),
|
||||
False,
|
||||
),
|
||||
) # fmt: skip
|
||||
_PARTICIPLE = ((("ем", "нн", "вш", "ющ", "щ"), True), (("ивш", "ывш", "ующ"), False))
|
||||
_VERB = (
|
||||
(("ла", "на", "ете", "йте", "ли", "й", "л", "ем", "н", "ло", "но", "ет", "ют", "ны", "ть", "ешь", "нно"), True),
|
||||
(
|
||||
(
|
||||
"ила", "ыла", "ена", "ейте", "уйте", "ите", "или", "ыли", "ей", "уй", "ил", "ыл", "им", "ым", "ен",
|
||||
"ило", "ыло", "ено", "ят", "ует", "уют", "ит", "ыт", "ены", "ить", "ыть", "ишь", "ую", "ю",
|
||||
),
|
||||
False,
|
||||
),
|
||||
) # fmt: skip
|
||||
_NOUN = (
|
||||
(
|
||||
(
|
||||
"иями", "ями", "ами", "ией", "иям", "ием", "иях", "ев", "ов", "ие", "ье", "еи", "ии", "ей", "ой",
|
||||
"ий", "ям", "ем", "ам", "ом", "ах", "ях", "ию", "ью", "ия", "ья", "а", "е", "и", "й", "о", "у",
|
||||
"ы", "ь", "ю", "я",
|
||||
),
|
||||
False,
|
||||
),
|
||||
) # fmt: skip
|
||||
_DERIVATIONAL = ((("ость", "ост"), False),)
|
||||
_SUPERLATIVE = ((("ейше", "ейш"), False),)
|
||||
|
||||
_CYRILLIC_WORD = re.compile(r"^[а-я]+$")
|
||||
|
||||
|
||||
def _strip(word: str, start: int, groups: tuple[tuple[tuple[str, ...], bool], ...]) -> str | None:
|
||||
"""Remove the longest matching suffix lying in word[start:], or `None` if none matches."""
|
||||
best: tuple[int, str] | None = None
|
||||
for suffixes, needs_a_ya in groups:
|
||||
for suffix in suffixes:
|
||||
if not word.endswith(suffix) or len(word) - len(suffix) < start:
|
||||
continue
|
||||
if needs_a_ya and (len(word) - len(suffix) - 1 < start or word[-len(suffix) - 1] not in "ая"):
|
||||
continue
|
||||
if best is None or len(suffix) > best[0]:
|
||||
best = (len(suffix), word[: -len(suffix)])
|
||||
return None if best is None else best[1]
|
||||
|
||||
|
||||
def _regions(word: str) -> tuple[int, int]:
|
||||
"""RV (after the first vowel) and R2 start indices."""
|
||||
rv = next((i + 1 for i, ch in enumerate(word) if ch in _VOWELS), len(word))
|
||||
|
||||
def after_vc(start: int) -> int:
|
||||
for i in range(start + 1, len(word)):
|
||||
if word[i] not in _VOWELS and word[i - 1] in _VOWELS:
|
||||
return i + 1
|
||||
return len(word)
|
||||
|
||||
return rv, after_vc(after_vc(0))
|
||||
|
||||
|
||||
@lru_cache(maxsize=65536)
|
||||
def stem(word: str) -> str:
|
||||
word = word.lower().replace("ё", "е")
|
||||
if not _CYRILLIC_WORD.match(word):
|
||||
return word
|
||||
rv, r2 = _regions(word)
|
||||
|
||||
# Step 1
|
||||
out = _strip(word, rv, _PERFECTIVE_GERUND)
|
||||
if out is None:
|
||||
word = _strip(word, rv, _REFLEXIVE) or word
|
||||
out = _strip(word, rv, _ADJECTIVE)
|
||||
if out is not None:
|
||||
out = _strip(out, rv, _PARTICIPLE) or out
|
||||
else:
|
||||
out = _strip(word, rv, _VERB)
|
||||
if out is None:
|
||||
out = _strip(word, rv, _NOUN)
|
||||
word = out if out is not None else word
|
||||
|
||||
# Step 2
|
||||
if word.endswith("и") and len(word) - 1 >= rv:
|
||||
word = word[:-1]
|
||||
# Step 3
|
||||
word = _strip(word, r2, _DERIVATIONAL) or word
|
||||
# Step 4
|
||||
if word.endswith("нн") and len(word) - 1 >= rv:
|
||||
return word[:-1]
|
||||
sup = _strip(word, rv, _SUPERLATIVE)
|
||||
if sup is not None:
|
||||
word = sup
|
||||
return word[:-1] if word.endswith("нн") else word
|
||||
if word.endswith("ь") and len(word) - 1 >= rv:
|
||||
word = word[:-1]
|
||||
return word
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005).
|
||||
|
||||
`run_until(now)` replays, in time order, every event that is due by `now` and still fresh
|
||||
(`config/scheduler.yaml`):
|
||||
|
||||
* med_reminder — a `doses` row (pending) per prescription time, then `system:med_reminder {dose_id, drug, dose}`;
|
||||
* missed — a reminded dose still pending `missed_after_hours` later → status `missed` → monitoring;
|
||||
* daily_checkin — `system:daily_checkin` at the check-in time;
|
||||
* scale_due — scales due by the module schedules (`config/modules.yaml`) → `system:scale_due`;
|
||||
* evaluation — once a day `MonitoringService.after_data_change()`: catches silence > N days (rule
|
||||
`no_contact_3d`) and missed doses even when the patient writes nothing.
|
||||
|
||||
The rules decide (ADR-007); the scheduler only turns their dialog actions into system events for the engine
|
||||
(`reach_out` → `system:reach_out`, `schedule_scale` → `system:scale_due`). Replies go to `integrations.outbox`.
|
||||
|
||||
Idempotent: a delivered event is stored in `scheduler_events` under a unique key and never sent again; a failed
|
||||
delivery is not stored and is retried on the next run while it is fresh (at least once). Only patients with
|
||||
consent (and adult) are contacted; while a crisis is open no reminders, check-ins or scales are sent.
|
||||
Time comes only from the arguments / `app.core.clock` (guardrail #4).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.modules import due_scales, load_modules
|
||||
from app.core.rules import Action
|
||||
from app.db import repo
|
||||
from app.db.clinical import DialogState, Dose, PatientProfile, Prescription
|
||||
from app.db.scheduler import SchedulerEvent
|
||||
from app.db.session import get_clinical_session
|
||||
from app.dialog.contracts import Event, Reply, get_engine
|
||||
from app.integrations.base import integrations
|
||||
from app.services.contracts import get_monitoring
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ config
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class DosesConfig(_Strict):
|
||||
late_hours: float = Field(gt=0)
|
||||
missed_after_hours: float = Field(gt=0)
|
||||
|
||||
|
||||
class Window(_Strict):
|
||||
time: time
|
||||
late_hours: float = Field(gt=0)
|
||||
|
||||
|
||||
class SchedulerConfig(_Strict):
|
||||
enabled: bool = True
|
||||
tick_seconds: float = Field(gt=0)
|
||||
doses: DosesConfig
|
||||
daily_checkin: Window
|
||||
scales: Window
|
||||
evaluation: Window
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_config(path: Path | None = None) -> SchedulerConfig:
|
||||
path = path or get_settings().config_dir / "scheduler.yaml"
|
||||
return SchedulerConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ jobs
|
||||
|
||||
# Same due time → this order: the dose before the check-in that asks about it.
|
||||
_ORDER = {"evaluation": 0, "missed": 1, "med_reminder": 2, "scale_due": 3, "daily_checkin": 4}
|
||||
|
||||
|
||||
@dataclass(order=True)
|
||||
class Job:
|
||||
due: datetime
|
||||
rank: int
|
||||
token: str
|
||||
key: str
|
||||
kind: str = field(compare=False)
|
||||
params: dict[str, Any] = field(default_factory=dict, compare=False)
|
||||
|
||||
|
||||
def _job(due: datetime, token: str, key: str, kind: str, **params: Any) -> Job:
|
||||
return Job(due=due, rank=_ORDER[kind], token=token, key=key, kind=kind, params=params)
|
||||
|
||||
|
||||
def _at(day: date, t: time, now: datetime) -> datetime:
|
||||
return datetime.combine(day, t, tzinfo=now.tzinfo)
|
||||
|
||||
|
||||
def _fresh(due: datetime, now: datetime, late_hours: float) -> bool:
|
||||
return due <= now < due + timedelta(hours=late_hours)
|
||||
|
||||
|
||||
def _window(day: date, w: Window, now: datetime) -> datetime | None:
|
||||
due = _at(day, w.time, now)
|
||||
return due if _fresh(due, now, w.late_hours) else None
|
||||
|
||||
|
||||
async def _dose_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
|
||||
jobs: list[Job] = []
|
||||
today = now.date()
|
||||
for rx in (await s.scalars(select(Prescription).where(Prescription.token == p.token))).all():
|
||||
start = rx.start_date or p.therapy_start
|
||||
for day in (today - timedelta(days=1), today): # a fresh dose is never older than a day
|
||||
if (start and day < start) or (rx.end_date and day > rx.end_date):
|
||||
continue
|
||||
for raw in (rx.schedule or {}).get("times", []):
|
||||
planned = _at(day, time.fromisoformat(raw), now)
|
||||
if _fresh(planned, now, cfg.doses.late_hours):
|
||||
key = f"dose:{rx.id}:{planned.isoformat(timespec='minutes')}"
|
||||
jobs.append(_job(planned, p.token, key, "med_reminder", rx_id=rx.id, planned_at=planned))
|
||||
return jobs
|
||||
|
||||
|
||||
async def _missed_jobs(s: AsyncSession, token: str, now: datetime, cfg: SchedulerConfig) -> list[Job]:
|
||||
after = timedelta(hours=cfg.doses.missed_after_hours)
|
||||
stmt = select(Dose).where(Dose.token == token, Dose.status == "pending", Dose.planned_at <= now - after)
|
||||
return [_job(d.planned_at + after, token, f"missed:{d.id}", "missed", dose_id=d.id) for d in await s.scalars(stmt)]
|
||||
|
||||
|
||||
async def _scale_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
|
||||
due_at = _window(now.date(), cfg.scales, now)
|
||||
if due_at is None:
|
||||
return []
|
||||
last: dict[str, date] = {}
|
||||
for r in await repo.list_scale_results(s, p.token):
|
||||
if r.complete:
|
||||
last[r.scale] = r.ts.astimezone(now.tzinfo).date()
|
||||
if p.therapy_start:
|
||||
# A scale never taken counts from the visit where therapy started, not as overdue on day 0.
|
||||
for mod in load_modules().modules.values():
|
||||
for entry in mod.schedule:
|
||||
last.setdefault(entry.scale, p.therapy_start)
|
||||
therapy_day = (now.date() - p.therapy_start).days if p.therapy_start else 0
|
||||
scales = due_scales(set(p.modules or []), therapy_day, last, now.date())
|
||||
return [_job(due_at, p.token, f"scale:{sc}:{now.date()}", "scale_due", scale=sc) for sc in scales]
|
||||
|
||||
|
||||
async def collect(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
|
||||
"""Due and fresh, not yet delivered events of all contactable patients, in time order."""
|
||||
cfg = cfg or load_config()
|
||||
jobs: list[Job] = []
|
||||
async with get_clinical_session() as s:
|
||||
for p in (await s.scalars(select(PatientProfile).order_by(PatientProfile.token))).all():
|
||||
state = await s.get(DialogState, p.token)
|
||||
data = state.data if state is not None else {}
|
||||
if data.get("consent") is not True or data.get("adult") is not True:
|
||||
continue
|
||||
mine = await _missed_jobs(s, p.token, now, cfg)
|
||||
if (due := _window(now.date(), cfg.evaluation, now)) is not None:
|
||||
mine.append(_job(due, p.token, f"eval:{now.date()}", "evaluation"))
|
||||
if not (data.get("crisis") or {}).get("open"):
|
||||
mine += await _dose_jobs(s, p, now, cfg)
|
||||
mine += await _scale_jobs(s, p, now, cfg)
|
||||
if (due := _window(now.date(), cfg.daily_checkin, now)) is not None:
|
||||
mine.append(_job(due, p.token, f"checkin:{now.date()}", "daily_checkin"))
|
||||
done = set((await s.scalars(select(SchedulerEvent.key).where(SchedulerEvent.token == p.token))).all())
|
||||
jobs += [j for j in mine if j.key not in done]
|
||||
return sorted(jobs)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ delivery
|
||||
|
||||
|
||||
async def _system(token: str, kind: str, **params: Any) -> None:
|
||||
replies: list[Reply] = await get_engine().handle(
|
||||
token,
|
||||
Event(type="system", system=kind, params=params), # type: ignore[arg-type]
|
||||
)
|
||||
if replies:
|
||||
await integrations.outbox.push(token, replies)
|
||||
|
||||
|
||||
async def _after_rules(token: str) -> None:
|
||||
"""Monitoring pass outside a dialog: hand the rules' dialog actions to the engine as system events."""
|
||||
actions: list[Action] = await get_monitoring().after_data_change(token)
|
||||
for a in actions:
|
||||
if a.type == "reach_out":
|
||||
await _system(token, "reach_out", **a.params)
|
||||
elif a.type == "schedule_scale" and a.params.get("scale"):
|
||||
await _system(token, "scale_due", scale=a.params["scale"], scheduled=bool(a.params.get("scheduled")))
|
||||
elif a.type == "crisis":
|
||||
log.warning("scheduler crisis action token=%s code=%s", token, a.code) # alert already persisted
|
||||
|
||||
|
||||
async def _ensure_dose(token: str, rx_id: int, planned_at: datetime) -> tuple[int, Prescription]:
|
||||
async with get_clinical_session() as s:
|
||||
rx = await s.get(Prescription, rx_id)
|
||||
assert rx is not None
|
||||
dose = await s.scalar(
|
||||
select(Dose).where(Dose.token == token, Dose.prescription_id == rx_id, Dose.planned_at == planned_at)
|
||||
)
|
||||
if dose is None:
|
||||
dose = Dose(token=token, prescription_id=rx_id, planned_at=planned_at, status="pending")
|
||||
s.add(dose)
|
||||
await s.flush()
|
||||
return dose.id, rx
|
||||
|
||||
|
||||
async def _mark_missed(dose_id: int) -> bool:
|
||||
async with get_clinical_session() as s:
|
||||
dose = await s.get(Dose, dose_id)
|
||||
if dose is None or dose.status != "pending":
|
||||
return False # answered meanwhile
|
||||
dose.status = "missed"
|
||||
return True
|
||||
|
||||
|
||||
async def _run(job: Job, now: datetime) -> None:
|
||||
if job.kind == "med_reminder":
|
||||
dose_id, rx = await _ensure_dose(job.token, job.params["rx_id"], job.params["planned_at"])
|
||||
await _system(job.token, "med_reminder", dose_id=dose_id, drug=rx.drug, dose=rx.dose)
|
||||
elif job.kind == "missed":
|
||||
if await _mark_missed(job.params["dose_id"]):
|
||||
await _after_rules(job.token)
|
||||
elif job.kind == "daily_checkin":
|
||||
await _system(job.token, "daily_checkin")
|
||||
elif job.kind == "scale_due":
|
||||
await _system(job.token, "scale_due", scale=job.params["scale"], scheduled=True)
|
||||
elif job.kind == "evaluation":
|
||||
await _after_rules(job.token)
|
||||
|
||||
|
||||
async def _done(job: Job, now: datetime) -> None:
|
||||
async with get_clinical_session() as s:
|
||||
s.add(SchedulerEvent(token=job.token, key=job.key, kind=job.kind, due_at=job.due, done_at=now))
|
||||
|
||||
|
||||
async def run_until(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
|
||||
"""Deliver every due event up to `now` in time order; returns the delivered jobs."""
|
||||
delivered: list[Job] = []
|
||||
for job in await collect(now, cfg):
|
||||
try:
|
||||
await _run(job, now)
|
||||
except Exception as exc: # noqa: BLE001 — one patient's failure must not stop the others
|
||||
log.error("scheduler job failed token=%s kind=%s error=%s", job.token, job.kind, type(exc).__name__)
|
||||
continue
|
||||
await _done(job, now)
|
||||
delivered.append(job)
|
||||
if delivered:
|
||||
log.info("scheduler delivered=%s", [(j.token, j.kind) for j in delivered])
|
||||
return delivered
|
||||
+34
-11
@@ -1,4 +1,4 @@
|
||||
"""Questionnaire jobs on the demo clock; clinical release disabled by default.
|
||||
"""Scheduler on the demo clock: questionnaire jobs when that release is on, else the demo scheduler (#13).
|
||||
|
||||
One scheduler per database. PostgreSQL's advisory transaction lock serializes
|
||||
workers; an asyncio lock does the same within a SQLite demonstration process.
|
||||
@@ -12,15 +12,20 @@ from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import load_policy
|
||||
from app.db import repo
|
||||
from app.db.clinical import PatientProfile
|
||||
from app.db.intake import QuestionnaireTask
|
||||
from app.db.session import get_clinical_session
|
||||
from app.dialog.contracts import Event, Reply, get_engine
|
||||
from app.integrations.base import integrations
|
||||
from app.scheduler import demo
|
||||
from app.services import monitoring
|
||||
from app.services import questionnaire as service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -42,16 +47,18 @@ async def plan():
|
||||
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
|
||||
if initial is None:
|
||||
continue
|
||||
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
|
||||
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
|
||||
last_activity = max([*diaries, initial.day])
|
||||
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
|
||||
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
|
||||
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
|
||||
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
|
||||
continue
|
||||
if local.hour >= p.daily_hour and not any(
|
||||
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
|
||||
):
|
||||
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
|
||||
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
|
||||
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
|
||||
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
|
||||
if local.hour >= hour and kind not in done:
|
||||
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
|
||||
last = {}
|
||||
for row in rows:
|
||||
if row.status != "complete":
|
||||
@@ -79,11 +86,20 @@ async def plan():
|
||||
after_crisis=crisis_date,
|
||||
policy=p,
|
||||
)
|
||||
if scales or local.hour >= p.daily_hour:
|
||||
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
|
||||
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
|
||||
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
|
||||
totals = initial.details.get("totals", {})
|
||||
modules = set(profile.modules or [])
|
||||
policy = load_policy()
|
||||
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
|
||||
wanted = {
|
||||
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
|
||||
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
|
||||
"rus_audit_s": True,
|
||||
}
|
||||
for sid in scales:
|
||||
if sid == "pcl5" and initial.answers.get("trauma") != 1:
|
||||
continue
|
||||
if wanted[sid]:
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
|
||||
|
||||
@@ -146,6 +162,9 @@ async def run_until_now():
|
||||
return
|
||||
await service.dispatch_notices()
|
||||
if not intake_enabled():
|
||||
# Questionnaire release off → the demo scheduler (doses, check-ins, scales, silence) runs instead.
|
||||
if demo.load_config().enabled:
|
||||
await demo.run_until(clock.now())
|
||||
return
|
||||
await plan()
|
||||
await deliver()
|
||||
@@ -158,12 +177,16 @@ async def _run():
|
||||
await run_until_now()
|
||||
except Exception as exc:
|
||||
log.error("questionnaire scheduler error=%s", type(exc).__name__)
|
||||
await asyncio.sleep(30)
|
||||
await asyncio.sleep(demo.load_config().tick_seconds)
|
||||
|
||||
|
||||
async def start():
|
||||
global _worker
|
||||
if _worker is None and intake_enabled():
|
||||
try:
|
||||
await monitoring.init_clock_persistence()
|
||||
except (OSError, SQLAlchemyError) as exc:
|
||||
log.error("clock persistence unavailable error=%s", type(exc).__name__)
|
||||
if _worker is None and (intake_enabled() or demo.load_config().enabled):
|
||||
_worker = asyncio.create_task(_run())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Monitoring service (#38): patient snapshot → deterministic rules → persisted alerts (ADR-007).
|
||||
|
||||
`after_data_change(token)` is called by the dialog engine after it commits clinical data (and by the scheduler
|
||||
on silence). It builds a `PatientSnapshot`, runs `app.core.rules.evaluate`, then in one transaction:
|
||||
* writes every `alert`/`crisis` action to `alerts` (a crisis is an `acute` alert) and, for a crisis,
|
||||
an audit record `crisis_acute` (guardrail #6);
|
||||
* applies `enhanced_monitoring` to the profile;
|
||||
* refreshes the cached `status_color` of the profile (ADR-006; the source of truth stays the open alerts).
|
||||
After the commit the doctor is notified through `integrations.notifier` (a crisis also goes to the doctor on
|
||||
duty), and only the *dialog* actions are returned to the engine — it never creates alerts itself.
|
||||
|
||||
A crisis rule whose acute alert is still open stays silent: the crisis script is already running and the
|
||||
doctor already knows, so it is neither restarted nor re-alerted (`rules.evaluate` never suppresses crises).
|
||||
|
||||
The virtual clock offset is persisted in `clock_state` (ADR-005) by `init_clock_persistence()`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import clock
|
||||
from app.core.rules import Action, evaluate, patient_status
|
||||
from app.core.types import AlertLevel
|
||||
from app.db import repo
|
||||
from app.db.audit import audit
|
||||
from app.db.session import get_clinical_session
|
||||
from app.integrations.base import integrations
|
||||
from app.services.contracts import register_monitoring
|
||||
from app.services.snapshot import build_snapshot
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_ACTOR = "system"
|
||||
# Notification address of the doctor on duty: acute alerts must reach someone even when the attending doctor
|
||||
# is off (SPEC: crisis → immediate escalation). The notifier maps it to the duty channel.
|
||||
ON_DUTY_DOCTOR = "on-duty"
|
||||
# Persisted by the service itself, never passed to the dialog engine.
|
||||
_SERVICE_ACTIONS = frozenset({"alert", "enhanced_monitoring"})
|
||||
|
||||
|
||||
async def refresh_status(session: AsyncSession, token: str) -> str:
|
||||
"""Recompute and cache the dashboard colour from open alerts (also call it after an ack)."""
|
||||
alerts = await repo.list_alerts(session, token=token, open_only=True)
|
||||
status = patient_status(AlertLevel[a.level.upper()] for a in alerts)
|
||||
await repo.upsert_profile(session, token=token, status_color=status.color)
|
||||
return status.color
|
||||
|
||||
|
||||
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
|
||||
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
|
||||
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
|
||||
return [a for a in actions if a.code not in running]
|
||||
|
||||
|
||||
class RuleMonitoring:
|
||||
async def after_data_change(self, token: str) -> list[Action]:
|
||||
now = clock.now()
|
||||
notify: list[tuple[str, str, str]] = [] # (doctor_id, level, text) — sent after the commit
|
||||
async with get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, token)
|
||||
snapshot = await build_snapshot(s, token, now, profile=profile)
|
||||
if profile is None or snapshot is None:
|
||||
return []
|
||||
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
|
||||
actions = _drop_running_crises(evaluate(snapshot), open_acute)
|
||||
for action in actions:
|
||||
if action.type == "enhanced_monitoring":
|
||||
profile.enhanced_monitoring = True
|
||||
if not action.creates_alert or action.level is None:
|
||||
continue
|
||||
alert = await repo.add_alert(
|
||||
s,
|
||||
token=token,
|
||||
level=action.level,
|
||||
code=action.code,
|
||||
reason=action.reason_ru,
|
||||
ts=now,
|
||||
params=action.params,
|
||||
)
|
||||
level = action.level.name.lower()
|
||||
notify.append((profile.doctor_id, level, action.reason_ru))
|
||||
if action.type == "crisis":
|
||||
await audit(
|
||||
s,
|
||||
actor=SYSTEM_ACTOR,
|
||||
action="crisis_acute",
|
||||
token=token,
|
||||
details={"alert_id": alert.id, "code": action.code},
|
||||
ts=now,
|
||||
)
|
||||
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
|
||||
await refresh_status(s, token)
|
||||
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
|
||||
for doctor_id, level, text in notify:
|
||||
await _notify(doctor_id, level, text, token)
|
||||
return [a for a in actions if a.type not in _SERVICE_ACTIONS]
|
||||
|
||||
async def is_in_crisis(self, token: str) -> bool:
|
||||
async with get_clinical_session() as s:
|
||||
alerts = await repo.list_alerts(s, token=token, open_only=True)
|
||||
return any(a.level == "acute" for a in alerts)
|
||||
|
||||
|
||||
async def _notify(doctor_id: str, level: str, text: str, token: str) -> None:
|
||||
# The alert is already committed and visible on the dashboard; a failing channel must not undo it.
|
||||
try:
|
||||
await integrations.notifier.send(doctor_id, level, text, token) # type: ignore[arg-type]
|
||||
except Exception as exc: # noqa: BLE001 — any channel error
|
||||
log.warning("notify failed token=%s level=%s error=%s", token, level, type(exc).__name__)
|
||||
|
||||
|
||||
register_monitoring(RuleMonitoring)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------- demo clock persistence
|
||||
|
||||
_writes: set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
async def _write_offset() -> None:
|
||||
# Writes the offset current at write time, so out-of-order tasks still leave the latest value stored.
|
||||
try:
|
||||
async with get_clinical_session() as s:
|
||||
await repo.save_clock_offset(s, clock.offset().total_seconds())
|
||||
except (OSError, SQLAlchemyError) as exc:
|
||||
log.warning("clock offset not saved error=%s", type(exc).__name__)
|
||||
|
||||
|
||||
def _save_offset(_: timedelta) -> None:
|
||||
"""Sync hook for `clock.register_persistence`: schedules the DB write on the running loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
log.warning("clock offset not saved: no running event loop")
|
||||
return
|
||||
task = loop.create_task(_write_offset())
|
||||
_writes.add(task)
|
||||
task.add_done_callback(_writes.discard)
|
||||
|
||||
|
||||
async def flush_clock_writes() -> None:
|
||||
"""Wait for pending offset writes (tests, shutdown)."""
|
||||
if _writes:
|
||||
await asyncio.gather(*list(_writes))
|
||||
|
||||
|
||||
async def init_clock_persistence() -> datetime:
|
||||
"""Restore the virtual clock offset from `clock_state` and save every later change there."""
|
||||
async with get_clinical_session() as s:
|
||||
stored = timedelta(seconds=await repo.load_clock_offset(s))
|
||||
clock.register_persistence(lambda: stored, _save_offset)
|
||||
log.info("clock offset restored seconds=%s", int(stored.total_seconds()))
|
||||
return clock.now()
|
||||
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
sex = profile.sex if profile else None
|
||||
if sex is None and prior:
|
||||
sex = prior.details.get("sex")
|
||||
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
|
||||
row = QuestionnaireRecord(
|
||||
id=uuid4().hex,
|
||||
token=ctx.token,
|
||||
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
version=content()["version"],
|
||||
policy_version=load_policy().version,
|
||||
started_at=ctx.now,
|
||||
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
|
||||
day=day,
|
||||
status="active",
|
||||
answers={},
|
||||
details={
|
||||
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
|
||||
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
|
||||
"answer_metadata": {},
|
||||
"task_id": task.id if task else None,
|
||||
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
|
||||
"day_index": (day - prior.day).days if prior else 0,
|
||||
},
|
||||
)
|
||||
ctx.session.add(row)
|
||||
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
return row
|
||||
|
||||
|
||||
def save_answer(row, question, value, now):
|
||||
def save_answer(row, question, value, now, *, derived=False):
|
||||
"""Anchor recall windows to administration, never to the day of a resumed answer."""
|
||||
day = row.day
|
||||
starts = {
|
||||
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
|
||||
"period": question.period,
|
||||
"period_start": starts[question.period].isoformat() if question.period in starts else None,
|
||||
"period_end": day.isoformat(),
|
||||
"source": "patient",
|
||||
"derived": False,
|
||||
"source": "derived" if derived else "patient",
|
||||
"derived": derived,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
|
||||
return row
|
||||
|
||||
|
||||
async def outstanding_task(session, token, kind):
|
||||
return await session.scalar(
|
||||
select(QuestionnaireTask).where(
|
||||
QuestionnaireTask.token == token,
|
||||
QuestionnaireTask.kind == kind,
|
||||
QuestionnaireTask.status.in_(("pending", "active")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def lock_patient(session, token):
|
||||
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
|
||||
# use the engine's in-process lock. Consent and answers share this transaction.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Patient snapshot for the rules engine, assembled from the clinical DB (ADR-007).
|
||||
|
||||
Maps storage names to `app.core.rules` names (`sleep_bucket` → `sleep_hours_bucket`, `alcohol_bucket` →
|
||||
`alcohol`), derives age from `birth_year` and the therapy day from `therapy_start`. Time comes in as `as_of`
|
||||
(from `app.core.clock`), never from the wall clock. Must not import `app.db.identity` (guardrail #5).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, get_args
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.rules import (
|
||||
CssrsRisk,
|
||||
DailyCheckin,
|
||||
DoseRecord,
|
||||
Module,
|
||||
PatientSnapshot,
|
||||
ScalePoint,
|
||||
)
|
||||
from app.db import repo
|
||||
from app.db.clinical import Dose, Message, PatientProfile
|
||||
|
||||
# Risk screens are stored as scale results but reach the rules as `cssrs_risk`, not as scale points.
|
||||
CSSRS_SCALES = frozenset({"cssrs_screen", "cssrs"})
|
||||
# A C-SSRS result counts as the current risk for this long (same window as PHQ-9 item 9 and the dialog's
|
||||
# C-SSRS cooldown): one old "high" must not restart the crisis protocol after the doctor has acted on it.
|
||||
CSSRS_RECENT = timedelta(hours=24)
|
||||
# Unknown sex/age fall back to the more cautious option: lower AUDIT-C cut-off, young-adult monitoring.
|
||||
_UNKNOWN_SEX = "f"
|
||||
_UNKNOWN_AGE = 0
|
||||
|
||||
_MODULES = frozenset(get_args(Module))
|
||||
_CSSRS_RISKS = frozenset(get_args(CssrsRisk))
|
||||
|
||||
|
||||
def _cssrs_risk(results: list[Any], as_of: datetime) -> CssrsRisk | None:
|
||||
recent = [r for r in results if r.scale in CSSRS_SCALES and as_of - CSSRS_RECENT < r.ts <= as_of]
|
||||
if not recent:
|
||||
return None
|
||||
severity = recent[-1].severity
|
||||
return severity if severity in _CSSRS_RISKS else None
|
||||
|
||||
|
||||
async def build_snapshot(
|
||||
session: AsyncSession, token: str, as_of: datetime, *, profile: PatientProfile | None = None
|
||||
) -> PatientSnapshot | None:
|
||||
"""Snapshot of everything the rules may look at; None if the patient has no clinical profile."""
|
||||
profile = profile or await repo.get_profile(session, token)
|
||||
if profile is None:
|
||||
return None
|
||||
|
||||
checkins = [
|
||||
DailyCheckin(
|
||||
day=c.day,
|
||||
mood=c.mood,
|
||||
med_taken=c.med_taken,
|
||||
sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type]
|
||||
alcohol=c.alcohol_bucket, # type: ignore[arg-type]
|
||||
side_effects=list(c.side_effects or []),
|
||||
quit_intent=bool(c.quit_intent),
|
||||
nightmares=c.nightmares,
|
||||
)
|
||||
for c in await repo.list_checkins(session, token)
|
||||
]
|
||||
results = [r for r in await repo.list_scale_results(session, token) if r.complete]
|
||||
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
|
||||
last_in = await session.scalar(
|
||||
select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in")
|
||||
)
|
||||
state = await repo.get_dialog_state(session, token)
|
||||
facts = (state.data.get("facts") or {}) if state is not None else {}
|
||||
|
||||
return PatientSnapshot(
|
||||
token=token,
|
||||
as_of=as_of,
|
||||
age=as_of.year - profile.birth_year if profile.birth_year else _UNKNOWN_AGE,
|
||||
sex=profile.sex if profile.sex in ("m", "f") else _UNKNOWN_SEX, # type: ignore[arg-type]
|
||||
modules={m for m in profile.modules or [] if m in _MODULES}, # type: ignore[misc]
|
||||
therapy_day=(as_of.date() - profile.therapy_start).days if profile.therapy_start else 0,
|
||||
checkins=checkins,
|
||||
scale_results=[
|
||||
ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled)
|
||||
for r in results
|
||||
if r.scale not in CSSRS_SCALES
|
||||
],
|
||||
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
|
||||
cssrs_risk=_cssrs_risk(results, as_of),
|
||||
weapon_access=facts.get("weapon_access"),
|
||||
last_patient_message_at=last_in,
|
||||
open_alert_codes=await repo.open_alert_codes(session, token),
|
||||
)
|
||||
@@ -7,3 +7,4 @@
|
||||
- `modules.yaml` — модули и расписания шкал
|
||||
- `lexicon/risk_ru.yaml` — лексикон риска
|
||||
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
|
||||
- `rag/` — база знаний (ADR-011): `sources.yaml` (документы, параметры поиска, синонимы) и `corpus/*.txt`, собранные из PDF в `docs/source/rag/`
|
||||
|
||||
+73
-178
@@ -1,189 +1,84 @@
|
||||
version: diary-v2-2026-10-03
|
||||
core:
|
||||
- id: mood
|
||||
text: 'Какое у Вас сегодня настроение?
|
||||
# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3.
|
||||
# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds
|
||||
# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only.
|
||||
# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran».
|
||||
version: diary-v3-2026-10-04
|
||||
|
||||
0 — самое плохое, 10 — самое хорошее.'
|
||||
options:
|
||||
- value: 0
|
||||
label: '0'
|
||||
- value: 1
|
||||
label: '1'
|
||||
- value: 2
|
||||
label: '2'
|
||||
- value: 3
|
||||
label: '3'
|
||||
- value: 4
|
||||
label: '4'
|
||||
- value: 5
|
||||
label: '5'
|
||||
- value: 6
|
||||
label: '6'
|
||||
- value: 7
|
||||
label: '7'
|
||||
- value: 8
|
||||
label: '8'
|
||||
- value: 9
|
||||
label: '9'
|
||||
- value: 10
|
||||
label: '10'
|
||||
# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00.
|
||||
morning:
|
||||
- id: sleep_hours
|
||||
text: Сколько часов Вы спали прошлой ночью?
|
||||
source: Consensus Sleep Diary
|
||||
text: Сколько часов Вы спали этой ночью?
|
||||
numeric: true
|
||||
- id: rested
|
||||
text: 'Насколько Вы выспались?
|
||||
|
||||
0 — совсем не выспались, 4 — полностью выспались.'
|
||||
max: 24
|
||||
- id: sleep_quality
|
||||
source: Consensus Sleep Diary
|
||||
text: Как Вы оцениваете качество своего сна?
|
||||
options:
|
||||
- value: 0
|
||||
label: '0'
|
||||
- value: 1
|
||||
label: '1'
|
||||
- value: 2
|
||||
label: '2'
|
||||
- value: 3
|
||||
label: '3'
|
||||
- value: 4
|
||||
label: '4'
|
||||
- {value: 0, label: Очень плохо}
|
||||
- {value: 1, label: Плохо}
|
||||
- {value: 2, label: Удовлетворительно}
|
||||
- {value: 3, label: Хорошо}
|
||||
- {value: 4, label: Очень хорошо}
|
||||
- id: alcohol_portions
|
||||
source: принцип Timeline Followback; порция по RUS-AUDIT
|
||||
text: |-
|
||||
Сколько стандартных порций алкоголя Вы выпили вчера?
|
||||
|
||||
Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0.
|
||||
numeric: true
|
||||
max: 50
|
||||
evening:
|
||||
- id: mood
|
||||
source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9)
|
||||
text: |-
|
||||
Как Вы оцениваете своё настроение сегодня?
|
||||
|
||||
0 — самое плохое, 10 — самое хорошее.
|
||||
options:
|
||||
- {value: 0, label: '0'}
|
||||
- {value: 1, label: '1'}
|
||||
- {value: 2, label: '2'}
|
||||
- {value: 3, label: '3'}
|
||||
- {value: 4, label: '4'}
|
||||
- {value: 5, label: '5'}
|
||||
- {value: 6, label: '6'}
|
||||
- {value: 7, label: '7'}
|
||||
- {value: 8, label: '8'}
|
||||
- {value: 9, label: '9'}
|
||||
- {value: 10, label: '10'}
|
||||
- id: medication
|
||||
text: Приняли ли Вы сегодня лекарства по схеме?
|
||||
source: КР ПТСР — дневник приёма лекарств
|
||||
text: Приняли ли Вы сегодня лекарства, назначенные врачом?
|
||||
options:
|
||||
- value: full
|
||||
label: По схеме
|
||||
- value: partial
|
||||
label: Частично
|
||||
- value: none
|
||||
label: Нет
|
||||
- value: not_prescribed
|
||||
label: Не назначены
|
||||
- {value: 'yes', label: Да}
|
||||
- {value: 'no', label: Нет}
|
||||
- {value: not_prescribed, label: Лекарства не назначены}
|
||||
- id: medication_reason
|
||||
text: Почему не приняли лекарства по схеме?
|
||||
source: КР ПТСР — дневник приёма лекарств
|
||||
text: Почему не приняли?
|
||||
options:
|
||||
- value: forgot
|
||||
label: Забыл(а)
|
||||
- value: side_effects
|
||||
label: Побочные эффекты
|
||||
- value: declined
|
||||
label: Не хочу
|
||||
- value: ran_out
|
||||
label: Закончились
|
||||
- value: other
|
||||
label: Другая причина
|
||||
- id: side_effects
|
||||
text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
|
||||
options: &id001
|
||||
- value: 1
|
||||
label: Да
|
||||
- value: 0
|
||||
label: Нет
|
||||
- id: substances
|
||||
text: Употребляли ли Вы сегодня алкоголь или другие вещества?
|
||||
options: *id001
|
||||
- id: comment
|
||||
text: Что хотите передать врачу?
|
||||
optional: true
|
||||
ptsd:
|
||||
- id: intrusions
|
||||
text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
|
||||
options: &id002
|
||||
- value: 0
|
||||
label: Совсем нет
|
||||
- value: 1
|
||||
label: Немного
|
||||
- value: 2
|
||||
label: Умеренно
|
||||
- value: 3
|
||||
label: Сильно
|
||||
- value: 4
|
||||
label: Крайне сильно
|
||||
- id: nightmares
|
||||
text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
|
||||
options: *id002
|
||||
- id: avoidance
|
||||
text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
|
||||
options: *id002
|
||||
- id: hypervigilance
|
||||
text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
|
||||
options: *id002
|
||||
- id: irritability
|
||||
text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
|
||||
options: *id002
|
||||
- {value: forgot, label: Забыл(а)}
|
||||
- {value: side_effects, label: Побочные эффекты}
|
||||
- {value: declined, label: Решил(а) не принимать}
|
||||
- {value: ran_out, label: Закончились}
|
||||
|
||||
# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer
|
||||
# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9.
|
||||
# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule).
|
||||
depression:
|
||||
- id: interest
|
||||
text: 'Насколько Вам сегодня было интересно и приятно что-то делать?
|
||||
|
||||
0 — совсем нет, 4 — крайне сильно.'
|
||||
options: *id002
|
||||
- id: depressed
|
||||
text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
|
||||
options: *id002
|
||||
- id: energy
|
||||
text: 'Насколько у Вас сегодня были силы и энергия?
|
||||
|
||||
0 — совсем нет, 4 — крайне сильно.'
|
||||
options: *id002
|
||||
- id: planned_activity
|
||||
text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
|
||||
options: *id001
|
||||
suicide:
|
||||
- id: death_thoughts
|
||||
text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
|
||||
options: *id001
|
||||
- id: thoughts_intensity
|
||||
text: 'Если да — насколько сильными они были?
|
||||
|
||||
0 — совсем слабые, 10 — максимально сильные.'
|
||||
form: phq9
|
||||
items_per_day: 3
|
||||
question: 'Насколько сегодня Вас беспокоило:'
|
||||
options:
|
||||
- value: 0
|
||||
label: '0'
|
||||
- value: 1
|
||||
label: '1'
|
||||
- value: 2
|
||||
label: '2'
|
||||
- value: 3
|
||||
label: '3'
|
||||
- value: 4
|
||||
label: '4'
|
||||
- value: 5
|
||||
label: '5'
|
||||
- value: 6
|
||||
label: '6'
|
||||
- value: 7
|
||||
label: '7'
|
||||
- value: 8
|
||||
label: '8'
|
||||
- value: 9
|
||||
label: '9'
|
||||
- value: 10
|
||||
label: '10'
|
||||
- id: hope
|
||||
text: 'Насколько Вы верите, что станет лучше?
|
||||
- {value: 0, label: Совсем нет}
|
||||
- {value: 1, label: Немного}
|
||||
- {value: 2, label: Умеренно}
|
||||
- {value: 3, label: Сильно}
|
||||
|
||||
0 — совсем не верю, 10 — полностью верю.'
|
||||
options:
|
||||
- value: 0
|
||||
label: '0'
|
||||
- value: 1
|
||||
label: '1'
|
||||
- value: 2
|
||||
label: '2'
|
||||
- value: 3
|
||||
label: '3'
|
||||
- value: 4
|
||||
label: '4'
|
||||
- value: 5
|
||||
label: '5'
|
||||
- value: 6
|
||||
label: '6'
|
||||
- value: 7
|
||||
label: '7'
|
||||
- value: 8
|
||||
label: '8'
|
||||
- value: 9
|
||||
label: '9'
|
||||
- value: 10
|
||||
label: '10'
|
||||
- id: burden
|
||||
text: Чувствовали ли Вы себя обузой для близких?
|
||||
options: *id001
|
||||
weekly: true
|
||||
# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the
|
||||
# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»).
|
||||
ptsd:
|
||||
form: pcl5
|
||||
items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18]
|
||||
question: 'Насколько сильно сегодня Вас беспокоили:'
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
version: intake-v2-2026-10-03
|
||||
version: intake-v3-2026-10-04
|
||||
enabled: false
|
||||
approval: null
|
||||
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
|
||||
content_review: null
|
||||
contact_review: null
|
||||
timezone: Europe/Moscow
|
||||
daily_hour: 19
|
||||
mood_baseline_days: 7
|
||||
mood_bad_days: 3
|
||||
mood_drop: 3
|
||||
sleep_bad_days: 3
|
||||
# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00.
|
||||
morning_hour: 8
|
||||
evening_hour: 20
|
||||
# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`.
|
||||
# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor.
|
||||
norm_days: 14
|
||||
week_days: 7
|
||||
min_norm_values: 7
|
||||
min_week_values: 4
|
||||
mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9
|
||||
depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier
|
||||
ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier
|
||||
sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor
|
||||
sleep_hours_below: 4
|
||||
nightmares_at: 3
|
||||
alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3)
|
||||
alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT
|
||||
adherence_below: 0.8 # share of days with medication taken over a week
|
||||
missed_medication_days: 3
|
||||
high_risk_silence_days: 2
|
||||
medication_days: [7, 14, 28]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is
|
||||
# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md).
|
||||
# Values follow the MVP PDF so the demo can reach a result and stage 2:
|
||||
# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table);
|
||||
# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022).
|
||||
version: mvp-v2-demo-2026-10-04
|
||||
approval:
|
||||
reviewer: "DEMO — синтетические данные, не клиническое согласование"
|
||||
decision_reference: "INTAKE_DEMO"
|
||||
approved_at: "2026-10-04T00:00:00+03:00"
|
||||
policy_version: mvp-v2-demo-2026-10-04
|
||||
cssrs_positive_policy: graded
|
||||
rus_audit_s_expand_from_zone: 2
|
||||
phq2_expand_at: 2
|
||||
tsq_expand_at: 6
|
||||
phq9_psychologist_at: 5
|
||||
phq9_specialist_at: 10
|
||||
phq9_priority_at: 20
|
||||
pcl5_specialist_at: 31
|
||||
pcl5_priority_at: 50
|
||||
pcl5_behavior_flag_at: 3
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
# Модуль «Лекарства»: спецификация для разработки
|
||||
|
||||
Источник: meds_module_spec.pdf, страниц: 10. Документ команды от Oct 4, 2026, автор @Andrey. Текст перенесён вручную и сверен с PDF: автоматическое извлечение ломает вёрстку выносок и таблиц. Символ «|» внутри ячеек заменён на «/».
|
||||
|
||||
--- стр. 1 ---
|
||||
|
||||
Модуль следит не за абстрактными «лекарствами», а за конкретным препаратом пациента. Он напоминает о приёме, узнаёт причины пропусков, спрашивает о побочных эффектах этого препарата и работает по 10 правилам (M1–M10). Данные взяты из двух клинических рекомендаций Минздрава: по депрессии (2024) и по ПТСР (2023).
|
||||
|
||||
Что передаём:
|
||||
- этот документ — логика модуля;
|
||||
- файл meds_reference.json (прилагается к ссылке) — справочник на 47 препаратов, тексты для пациента и параметры правил;
|
||||
- вкладка «Справочник препаратов» — тот же справочник в виде таблицы, для проверки медиком.
|
||||
|
||||
Как модуль встраивается в этап 2. Вечерний вопрос о лекарствах, опрос о побочных эффектах на 7-й, 14-й и 28-й день и флаги «меньше 80% дней» и «пропуск 3 дня подряд» не меняются. Модуль добавляет к ним данные о препарате и новые правила.
|
||||
|
||||
| В MVP | После хакатона |
|
||||
|---|---|
|
||||
| Справочник из JSON | Тексты инструкций из ГРЛС в карточке препарата |
|
||||
| Назначение: ввод в кабинете врача или из имитации ЕМИАС | Назначения из ЕМИАС (электронные рецепты) |
|
||||
| Напоминание о приёме и отметка в дневнике | Схема постепенной отмены препарата |
|
||||
| Опрос о побочных эффектах по профилю препарата | Ввод результатов анализов |
|
||||
| Правила M1–M10 и блок в сводке врачу | Проверка взаимодействий для врача |
|
||||
|
||||
--- стр. 2 ---
|
||||
|
||||
Границы: бот следит, но не лечит
|
||||
|
||||
Препарат и дозу всегда выбирает врач. Если приложение начнёт давать советы о лечении, его могут счесть медицинским изделием, а к ним требования строже.
|
||||
|
||||
| Бот делает | Бот не делает никогда |
|
||||
|---|---|
|
||||
| Хранит назначение врача: препарат, дозу, время приёма | Не предлагает препарат, дозу, смену или отмену |
|
||||
| Напоминает о приёме и отмечает, принят ли препарат | На вопросы «можно ли бросить» или «можно ли увеличить дозу» отвечает только «обсудите с врачом» и ставит флаг |
|
||||
| Спрашивает о побочных эффектах, характерных для этого препарата | Не исправляет и не блокирует дозу вне диапазона — только просит проверить ввод (M10) |
|
||||
| Показывает карточку препарата из проверенных источников | Не проверяет для пациента взаимодействия лекарств |
|
||||
| Сигналит врачу по правилам M1–M10 | Не ставит диагноз и не оценивает, «работает ли» лечение, — это делает врач по шкалам |
|
||||
|
||||
Модель данных
|
||||
|
||||
Шесть сущностей. Drug читается из JSON и в приложении не редактируется, остальные создаёт приложение.
|
||||
|
||||
| Сущность | Поля | Откуда берётся |
|
||||
|---|---|---|
|
||||
| Drug | id, name_ru, class, indications.{depression/ptsd}.{min_mg, max_mg, start_mg, off_label}, diary_items[], red_flags[], monitoring[], withdrawal_risk, overdose_danger, max_course_days | meds_reference.json |
|
||||
|
||||
--- стр. 3 ---
|
||||
|
||||
| Сущность | Поля | Откуда берётся |
|
||||
|---|---|---|
|
||||
| Prescription | id, patient_id, drug_id, indication (F32/F33/F43.1), dose_mg_per_day, times[] (например, "09:00"), start_date, source (emias / doctor / patient), status (active / stopped), history[] (дата, старая и новая доза) | имитация ЕМИАС или кабинет врача; ввод пациентом — с пометкой «со слов пациента» |
|
||||
| DoseLog | prescription_id, date, taken (bool), reason (forgot / side_effects / decided_not_to / ran_out), logged_at | вечерний дневник |
|
||||
| SideEffectReport | date, trigger (scheduled / missed_dose / button), fibser.{freq, intensity, burden}, items[]: {item_id, severity: mild/severe}, red_flags[] | опрос о побочных эффектах |
|
||||
| MedEvent | prescription_id, type (start / dose_change / stop), date, baseline_scale_ids[] | создаётся при изменении Prescription |
|
||||
| MedFlag | rule_id (M1…M10), patient_id, prescription_id, created_at, recipient, due_at, status (open / acknowledged), evidence (ответы, по которым сработало правило) | движок правил |
|
||||
|
||||
Связь с расписанием этапа 2. Любой MedEvent включает строку «Начало или смена лекарств»: полные шкалы и опрос о побочных эффектах на 7-й, 14-й, 28-й день, далее раз в 4 недели. Исходный замер для M5 — последняя полная шкала за 14 дней до события. Если её нет, шкалу проводят в день события.
|
||||
|
||||
Дневник и опросы
|
||||
|
||||
В обычный день добавляется только отметка по каждому препарату. Подробно спрашиваем по расписанию или после пропуска.
|
||||
|
||||
--- стр. 4 ---
|
||||
|
||||
1. Напоминание. Push-уведомление во время из Prescription.times: «Время принять {препарат} {доза}» и кнопка «Принял(а)». Нажатие сразу создаёт DoseLog с taken = true, и вечером этот препарат уже не спрашиваем.
|
||||
|
||||
2. Вечерний вопрос (уже есть в ядре этапа 2), теперь по каждому препарату:
|
||||
1. «Приняли ли Вы сегодня лекарства, назначенные врачом?» — да, все / не все / нет.
|
||||
2. Если «не все» — выбрать, какие не приняты.
|
||||
3. По каждому непринятому — причина: забыл(а) · побочные эффекты · решил(а) не принимать · закончились.
|
||||
Причина напрямую запускает правила: «побочные эффекты» → внеплановый опрос (п. 3) и M8, «решил(а) не принимать» → M2, «закончились» → M3.
|
||||
|
||||
3. Опрос о побочных эффектах. Проводится в трёх случаях: по расписанию этапа 2 (7-й, 14-й, 28-й день, далее раз в 4 недели), после пропуска «из-за побочных эффектов» и по кнопке «Сообщить о побочном эффекте», которая доступна всегда.
|
||||
1. Три вопроса FIBSER: как часто, насколько сильно, насколько мешают жить (из этапа 2).
|
||||
2. Если частота > 0 — «Что именно беспокоит?»: список из diary_items всех активных препаратов без повторов, плюс «другое» с текстом. У каждого пункта два ответа: немного / сильно мешает.
|
||||
3. Вопросы о срочных симптомах — только из red_flags активных препаратов, с ответом да / нет. Ответ «да» → M7.
|
||||
Список пунктов для препарата формируется так. Для 16 антидепрессантов берём эффекты с оценкой «++» и «+++» из таблицы 3 КР по депрессии. Для остальных препаратов — эффекты, которые названы в тексте КР. В JSON список уже собран: например, у сертралина это тошнота, бессонница, сексуальные проблемы и головная боль.
|
||||
|
||||
4. Карточки о сроках эффекта. Текст expect_effect_depression показываем в 1-й и 14-й день после начала, expect_effect_ptsd — в 1-й и 7-й. Тексты лежат в JSON, в разделе texts.
|
||||
|
||||
--- стр. 5 ---
|
||||
|
||||
Контрольные точки после начала лечения
|
||||
|
||||
[Изображение: текст перенесён вручную]
|
||||
Схема «Ответ оценивают к 4–6 неделе, лечение длится месяцы». Подзаголовок: «Фазы после начала или смены препарата: что делает модуль и что говорят КР». Таблица по фазам:
|
||||
|
||||
| Строка | Недели 1–2 | Недели 3–6 | Месяцы и годы |
|
||||
|---|---|---|---|
|
||||
| Модуль (что делает бот) | Дни 7 и 14: шкалы и опрос о побочных. Карточка «когда ждать эффекта» | День 28: шкалы, побочные и проверка ответа — M5. Бензодиазепин дольше допустимого — M4 | Шкалы и опрос о побочных раз в 4 недели до конца курса |
|
||||
| Депрессия (КР 2024) | Раннее улучшение (1–2 недели) — благоприятный признак | Эффект через 3–4 недели, у СИОЗС — до 6–8 недель; снижение на 25–50% — ответ недостаточен | Тот же препарат и доза 4–6 мес. после улучшения; при РДР — 3–5 лет |
|
||||
| ПТСР (КР 2023) | СИОЗС: в 1-ю неделю тревога может усилиться; следить за суицидальным риском | Эффект оценивают через 4–6 недель; бензодиазепины — не дольше 3–4 недель | Курс 6–12 месяцев после стабилизации |
|
||||
|
||||
Подпись под схемой: «Фазы после начала лечения · модуль, депрессия, ПТСР».
|
||||
|
||||
M5 проверяет ответ на 28-й день при обоих диагнозах. Для ПТСР это ранняя граница окна из КР: окончательно эффект оценивают до 6-й недели, поэтому флаг — повод для врача посмотреть, а не сигнал тревоги.
|
||||
|
||||
Правила сигналов M1–M10
|
||||
|
||||
К флагам этапа 2 по лекарствам (меньше 80% дней с приёмом, пропуск 3 дня подряд, тяжёлый день алкоголя при приёме лекарств) добавляются десять правил. Пороги и сроки хранятся в rules[].params в JSON и меняются без правки кода. Строки с пометкой «черновик» нужно согласовать с психиатром.
|
||||
|
||||
--- стр. 6 ---
|
||||
|
||||
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|
||||
|---|---|---|---|---|
|
||||
| M1 | У препарата withdrawal_risk = high (венлафаксин, пароксетин, ТЦА, бензодиазепины) и он пропущен 2 дня подряд | Текст withdrawal_missed и вопрос «Стало ли хуже после пропуска?» | Лечащий психиатр: ответ «хуже» — 24 ч, иначе — до 3 рабочих дней. Черновик | КР депрессия 3.6; КР ПТСР 3.2 |
|
||||
| M2 | Причина пропуска «решил(а) не принимать» | Тексты dont_stop_alone (+ why_continue_depression при F32/F33), флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В; КР ПТСР 6.1 |
|
||||
| M3 | Причина «закончились» | Текст ran_out; 2 дня подряд → флаг | Лечащий врач, до 3 рабочих дней. Черновик | команда |
|
||||
| M4 | Бензодиазепин принимается дольше max_course_days | Отметка в кабинете и сводке | Лечащий психиатр, к следующему визиту | КР ПТСР 3.2 |
|
||||
| M5 | 28-й день после MedEvent: PHQ-9 снизился меньше чем на 50% (F32/F33) или PCL-5 — меньше чем на 10 баллов (F43.1) от исходного | Флаг «нет ответа к 4-й неделе» | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия 3.1–3.2; КР ПТСР 3.2; пороги ответа — этап 2 |
|
||||
|
||||
--- стр. 7 ---
|
||||
|
||||
| ID | Условие | Что делает приложение | Кто получает, срок | Основание |
|
||||
|---|---|---|---|---|
|
||||
| M6 | Сработал сигнал C-SSRS (умеренный или высокий риск), а у пациента активен препарат с overdose_danger | Пометка «препарат опасен при передозировке» в этом же сигнале, новый сигнал не создаётся | Как у сигнала C-SSRS | КР депрессия 3.1.3 |
|
||||
| M7 | Пациент отметил срочный симптом из red_flags | Текст red_flag (врач сегодня; при тяжёлом состоянии 103/112), флаг | Лечащий психиатр, 24 ч. Черновик | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
|
||||
| M8 | FIBSER «насколько мешают» ≥ 5 из 6 или пропуск «из-за побочных» в 2 из 7 дней | Текст side_effects_general, флаг | Лечащий психиатр, до 3 рабочих дней. Черновик | КР депрессия, прил. В |
|
||||
| M9 | У активного препарата есть monitoring | Строка «Контроль по КР: …» в сводке врачу, это не сигнал | Лечащий врач, в сводке | КР депрессия 3.1, 3.3; КР ПТСР 3.2 |
|
||||
| M10 | При сохранении назначения доза вне min_mg – max_mg для этого диагноза | «Проверьте ввод» тому, кто вводит; пометка в сводке; сохранение не блокируется | Тот, кто вводит, сразу | Приложения А3 обеих КР |
|
||||
|
||||
Все флаги попадают в кабинет врача с датой, правилом и ответами, по которым правило сработало, как в этапе 2.
|
||||
|
||||
Карточка препарата, сводка врачу, демо
|
||||
|
||||
Карточка препарата построена по списку «что сообщить пациенту» из КР по депрессии (приложение В). Поля с источником «инструкция» в MVP показывают «Уточните у врача или в инструкции». Для 2–3 демо-препаратов их можно заполнить вручную.
|
||||
|
||||
--- стр. 8 ---
|
||||
|
||||
| Поле | Источник | Пример: сертралин при ПТСР |
|
||||
|---|---|---|
|
||||
| Название, доза, время приёма | Prescription | Сертралин, 50 мг в сутки, в 09:00 |
|
||||
| Когда ждать эффекта | indications.*.expected_response | Эффект оценивают через 4–6 недель |
|
||||
| Сколько принимать | indications.*.course + решение врача | Обычно 6–12 месяцев после стабилизации |
|
||||
| Частые побочные эффекты | diary_items | Тошнота, бессонница, сексуальные проблемы, головная боль |
|
||||
| Когда срочно к врачу | red_flags | Необычные кровотечения или синяки |
|
||||
| Не прекращать самостоятельно | texts.dont_stop_alone | общий текст |
|
||||
| Как принимать, связь с едой, что делать при пропуске | инструкция (ГРЛС) | — |
|
||||
| Алкоголь, вождение, беременность | инструкция (ГРЛС) | — |
|
||||
|
||||
Блок «Лекарства» в сводке врачу (дополняет сводку этапа 2), по каждому препарату:
|
||||
- препарат, доза, дата начала и неделя приёма рядом с ожидаемым сроком ответа (например, «неделя 3 из 4–6»);
|
||||
- доля дней с приёмом за 4 недели и причины пропусков;
|
||||
- побочные эффекты: FIBSER и пункты «что именно» по замерам, срочные симптомы;
|
||||
- ответ на 28-й день (M5), контроль по КР (M9), пометки M4 и M10.
|
||||
|
||||
Демо: ветеран Иван, ПТСР. Этот сценарий стыкуется с демо этапа 2: там ухудшение на 3-й неделе, здесь видна его причина.
|
||||
|
||||
--- стр. 9 ---
|
||||
|
||||
1. День 0. Врач в кабинете назначает сертралин 50 мг в сутки (препарат 1-й линии по КР ПТСР). Включается расписание «Начало или смена лекарств», пациент получает карточку expect_effect_ptsd.
|
||||
2. День 3. Отметка «не принял — побочные эффекты». Внеплановый опрос: тошнота «немного», бессонница «сильно мешает».
|
||||
3. День 7. Плановые шкалы и опрос о побочных эффектах.
|
||||
4. Дни 17–19. Три дня подряд «решил не принимать». На 17-й день срабатывает M2 (карточка и флаг врачу), на 19-й — флаг этапа 2 «пропуск 3 дня».
|
||||
5. Неделя 3. Ухудшение по дневнику → внеплановый PCL-5 → повторный триаж (сценарий этапа 2). В сводке врач видит, что ухудшение совпало с прекращением приёма из-за бессонницы.
|
||||
|
||||
Для питча: КР по ПТСР (раздел 3.2) прямо рекомендуют для соблюдения режима «ведение дневника приёма лекарств или телефонное приложение с напоминанием». В разделе 6.1 сказано, что пациенты часто бросают лекарства после улучшения и состояние ухудшается.
|
||||
|
||||
Черновые параметры — согласовать с психиатрами
|
||||
|
||||
Код можно писать сейчас: все числа ниже лежат в JSON и заменяются без правки логики. Вопросы к врачам по этапу 2 собраны в документе «Этап 2: вопросы к психиатрам».
|
||||
|
||||
| Параметр | Черновое значение | Основание |
|
||||
|---|---|---|
|
||||
| M1: сколько дней пропуска препарата с риском отмены | 2 дня подряд | риск — КР; число — команда |
|
||||
| M4: предел курса бензодиазепинов | 21 день; диазепам 28; алпразолам 90 | КР ПТСР 3.2 |
|
||||
| M5: день проверки ответа | 28-й день для обоих диагнозов | КР: депрессия 3–4 недели, ПТСР 4–6 недель |
|
||||
| M5: исходный замер | последняя полная шкала за 14 дней до старта, иначе в день старта | команда |
|
||||
| M8: «сильно мешают» | FIBSER-бремя ≥ 5 из 6 или 2 пропуска из-за побочных за 7 дней | порог FIBSER сверить с оригиналом шкалы |
|
||||
|
||||
--- стр. 10 ---
|
||||
|
||||
| Параметр | Черновое значение | Основание |
|
||||
|---|---|---|
|
||||
| M7: список срочных симптомов и текст пациенту | 5 симптомов из JSON, текст red_flag | симптомы — КР; текст — команда |
|
||||
| Сроки реакции | 24 ч / до 3 рабочих дней / к визиту | как в этапе 2 |
|
||||
| Ввод назначения пациентом | разрешён, с пометкой «со слов пациента» | команда |
|
||||
| Все тексты для пациента из texts | черновики | вычитать врачу |
|
||||
|
||||
Источники
|
||||
- Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство», Минздрав РФ, 2024, Российское общество психиатров. Использованы разделы 3.1–3.6 и приложения А3 (табл. 2 — дозы, табл. 3 — побочные эффекты), Б5 (синдром отмены), В (информация для пациента).
|
||||
- Клинические рекомендации «Посттравматическое стрессовое расстройство», Минздрав РФ, 2023, ID 753, Российское общество психиатров. Использованы разделы 3.2 и 6.1, приложения А3 (дозы), Б1 (алгоритм терапии), В. Срок пересмотра — не позднее 2025 года: проверить, не вышла ли новая версия.
|
||||
- «Этап 2: наблюдение после диагноза» — наш документ о расписании, флагах и сроках реакции.
|
||||
|
||||
Расхождения внутри КР отмечены в JSON в поле note. Пример: хлорпротиксен в тексте КР по ПТСР — 15–100 мг, а в таблице — 25–150 мг.
|
||||
@@ -0,0 +1,108 @@
|
||||
# База знаний RAG (#60): документы, параметры поиска, синонимы.
|
||||
# Тексты в corpus/ собраны из PDF конвертером docs/source/rag/pdf_to_txt.py — правьте PDF/figures/manual
|
||||
# и перегенерируйте, а не редактируйте txt руками.
|
||||
#
|
||||
# audiences — кому можно показывать выдержки:
|
||||
# doctor — врач (дашборд, подсказки по КР);
|
||||
# patient — пациент (только экстрактивно, без дозировок, с припиской «решение за врачом»);
|
||||
# team — внутренние документы команды (логика модулей), пациенту и врачу не показываются.
|
||||
|
||||
documents:
|
||||
- id: kr_ptsd
|
||||
file: corpus/kr_ptsd.txt
|
||||
title: "Клинические рекомендации «Посттравматическое стрессовое расстройство»"
|
||||
short: "КР ПТСР 2023"
|
||||
issuer: "Минздрав РФ, Российское общество психиатров"
|
||||
year: 2023
|
||||
icd: [F43.1]
|
||||
audiences: [doctor, patient]
|
||||
- id: kr_depression
|
||||
file: corpus/kr_depression.txt
|
||||
title: "Клинические рекомендации «Депрессивный эпизод, рекуррентное депрессивное расстройство»"
|
||||
short: "КР Депрессия 2024"
|
||||
issuer: "Минздрав РФ, Российское общество психиатров"
|
||||
year: 2024
|
||||
icd: [F32, F33]
|
||||
audiences: [doctor, patient]
|
||||
- id: meds_module_spec
|
||||
file: corpus/meds_module_spec.txt
|
||||
title: "Модуль «Лекарства»: спецификация для разработки"
|
||||
short: "Спецификация модуля «Лекарства»"
|
||||
issuer: "команда MedITron"
|
||||
year: 2026
|
||||
icd: []
|
||||
audiences: [team]
|
||||
|
||||
chunking:
|
||||
max_chars: 1200 # целевой размер фрагмента; абзац длиннее режется по предложениям
|
||||
min_chars: 200 # короткий хвост раздела приклеивается к предыдущему фрагменту того же раздела
|
||||
# Разделы, которые не индексируются (шум для поиска). Сравнение — по началу заголовка.
|
||||
skip_sections:
|
||||
- "Список литературы"
|
||||
- "Список сокращений" # аббревиатуры раскрываются через synonyms ниже, сам список только засоряет поиск
|
||||
|
||||
retrieval:
|
||||
top_k: 5
|
||||
bm25_k1: 1.5
|
||||
bm25_b: 0.75
|
||||
title_weight: 2 # заголовок раздела повторяется в индексе столько раз — совпадение с ним весомее
|
||||
min_score: 3.0 # ниже — считаем, что в базе нет ответа
|
||||
rerank_candidates: 30 # сколько лучших по BM25 фрагментов переранжировать по самому плотному отрывку
|
||||
window_sentences: 2 # размер отрывка (предложений подряд)
|
||||
window_weight: 0.5 # вес оценки отрывка относительно BM25
|
||||
# Множитель оценки для разделов (по началу заголовка) в зависимости от аудитории.
|
||||
# «Приложение В» в обеих КР — «Информация для пациента»: написано для пациента, ему и показываем в первую очередь.
|
||||
# «Приложение Г» — бланки шкал и история их валидации, «Критерии оценки качества» — для организаторов
|
||||
# здравоохранения: пациенту это шум.
|
||||
section_boost:
|
||||
patient:
|
||||
"Приложение В": 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 # в скольких лучших фрагментах ищем отрывок
|
||||
max_passages: 2 # отрывков в ответе (второй — только если почти так же хорош)
|
||||
second_passage_ratio: 0.8
|
||||
max_sentences: 3 # предложений в отрывке (подряд, как в источнике)
|
||||
neighbor_ratio: 0.5 # соседнее предложение входит в отрывок, если его релевантность >= доли лучшего
|
||||
max_chars: 600
|
||||
min_coverage: 0.5 # доля «веса» слов вопроса, найденная в отрывках; ниже — «ответа нет»
|
||||
|
||||
# Синонимы и аббревиатуры: запрос расширяется всеми формами группы (сравнение — по основам слов).
|
||||
synonyms:
|
||||
- ["птср", "посттравматическое стрессовое расстройство"]
|
||||
- ["кптср", "комплексное посттравматическое стрессовое расстройство"]
|
||||
- ["рдр", "рекуррентное депрессивное расстройство"]
|
||||
- ["дэ", "депрессивный эпизод"]
|
||||
- ["ад", "антидепрессант"]
|
||||
- ["сиозс", "селективные ингибиторы обратного захвата серотонина"]
|
||||
- ["сиозсн", "ингибиторы обратного захвата серотонина и норадреналина"]
|
||||
- ["тца", "трициклические антидепрессанты"]
|
||||
- ["кпт", "когнитивно-поведенческая психотерапия"]
|
||||
- ["дпдг", "emdr", "десенсибилизация и переработка травмы движениями глаз"]
|
||||
- ["сопа", "синдром отмены антидепрессантов"]
|
||||
- ["эст", "электросудорожная терапия"]
|
||||
- ["ртмс", "транскраниальная магнитная стимуляция"]
|
||||
- ["кошмар", "кошмарные сновидения"]
|
||||
- ["бессонница", "инсомния", "нарушения сна"]
|
||||
- ["флешбэк", "флэшбек", "навязчивые воспоминания"]
|
||||
- ["суицид", "самоубийство", "суицидальные мысли"]
|
||||
# Разговорная лексика пациента -> термины КР.
|
||||
- ["таблетки", "препарат", "лекарство"]
|
||||
- ["бросить", "отменять", "отменить", "отмена", "прекратить", "прекращение приема"]
|
||||
- ["подействовать", "действовать", "помогать", "эффект", "улучшение"]
|
||||
- ["антидепрессивный", "тимоаналептический"]
|
||||
- ["привыкание", "зависимость"]
|
||||
- ["спорт", "физическая активность", "физические упражнения", "физическая нагрузка"]
|
||||
- ["побочка", "побочные эффекты", "нежелательные явления"]
|
||||
- ["тошнит", "тошнота"]
|
||||
- ["выпить", "спиртное", "алкоголь"]
|
||||
- ["сколько длится", "продолжительность", "длительность"]
|
||||
- ["психолог", "психотерапия"]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005). Local times are in settings.tz.
|
||||
# Read by app/scheduler/demo.py; used only while the questionnaire release (intake_runtime.yaml) is off.
|
||||
#
|
||||
# Every event is delivered once (idempotency key in `scheduler_events`) and only while it is fresh: an event
|
||||
# older than its late window is not sent at all — after a long jump of the demo clock the patient does not
|
||||
# receive a pile of yesterday's reminders, and an undelivered reminder never turns into a "missed" dose.
|
||||
|
||||
enabled: true
|
||||
tick_seconds: 30
|
||||
|
||||
doses:
|
||||
late_hours: 2 # reminder not delivered if the dose time is older than this
|
||||
missed_after_hours: 2 # reminded dose without an answer → status "missed" (SPEC §8)
|
||||
|
||||
daily_checkin:
|
||||
time: "20:00"
|
||||
late_hours: 4
|
||||
|
||||
scales:
|
||||
time: "10:00" # scales due today (config/modules.yaml schedules) are offered from this hour
|
||||
late_hours: 10
|
||||
|
||||
evaluation:
|
||||
time: "09:00" # daily monitoring pass: silence > N days, missed doses → caring contact
|
||||
late_hours: 15
|
||||
@@ -16,6 +16,9 @@ settings:
|
||||
crisis_flags: [phq9_item9_positive, cssrs_high]
|
||||
# Шкала-скрининг суицидального риска (POSSIBLE и положительный п.9 PHQ-9 → эта шкала).
|
||||
risk_screen_scale: cssrs_screen
|
||||
# Брошенный шаг (чек-ин, шкала): если пациент молчит дольше N часов, следующее событие сначала возвращает
|
||||
# диалог в idle (уже данные ответы сохранены), затем обрабатывается. 0 — выключено. На кризис не действует.
|
||||
abandon_after_hours: 6
|
||||
|
||||
commands:
|
||||
# Текстовые команды отзыва согласия (сравниваются после приведения к нижнему регистру).
|
||||
@@ -60,6 +63,7 @@ idle:
|
||||
buttons:
|
||||
checkin: "Как я сегодня"
|
||||
med_question: "Вопрос о лекарстве"
|
||||
kb_question: "Вопрос о лечении"
|
||||
contact_doctor: "Связаться с врачом"
|
||||
revoke: "Отозвать согласие"
|
||||
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
|
||||
|
||||
@@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн
|
||||
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
|
||||
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
|
||||
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
|
||||
side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?"
|
||||
numeric_portions: "Введите число стандартных порций, например 0 или 2."
|
||||
diary_done: "Дневник на сегодня уже заполнен. Спасибо!"
|
||||
periods:
|
||||
since_last_contact: "С момента Вашего последнего ответа на эти вопросы."
|
||||
past_month: "За последний месяц."
|
||||
lifetime: "За всю жизнь."
|
||||
past_3_months: "За последние три месяца."
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
|
||||
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
|
||||
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
|
||||
|
||||
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
|
||||
prompt: |-
|
||||
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
|
||||
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
|
||||
|
||||
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
|
||||
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
|
||||
|
||||
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
|
||||
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
|
||||
question_starts:
|
||||
- "что такое"
|
||||
- "что значит"
|
||||
- "что означает"
|
||||
- "почему"
|
||||
- "зачем"
|
||||
- "как"
|
||||
- "когда"
|
||||
- "сколько"
|
||||
- "можно ли"
|
||||
- "нужно ли"
|
||||
- "надо ли"
|
||||
- "помогает ли"
|
||||
- "поможет ли"
|
||||
- "правда ли"
|
||||
- "бывает ли"
|
||||
- "что делать"
|
||||
- "что будет"
|
||||
- "чем"
|
||||
- "где"
|
||||
- "есть ли"
|
||||
|
||||
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
|
||||
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
|
||||
small_talk:
|
||||
- "как дела"
|
||||
- "как ты"
|
||||
- "как вы"
|
||||
- "как жизнь"
|
||||
- "как поживаете"
|
||||
- "что нового"
|
||||
- "кто ты"
|
||||
- "ты кто"
|
||||
- "ты бот"
|
||||
- "ты робот"
|
||||
- "привет"
|
||||
- "спасибо"
|
||||
@@ -0,0 +1,98 @@
|
||||
# Тексты приёма препарата (issue #37): напоминание, пропуск, побочные эффекты, вопрос о лекарстве, ответы
|
||||
# на действия правил med_info (отсроченный эффект, алкоголь, побочные эффекты).
|
||||
# СТАТУС: черновик, утверждает психиатр (#8).
|
||||
#
|
||||
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам и схеме приёма (решает врач);
|
||||
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом). Ответы о
|
||||
# препарате берутся только из выдержки инструкции ГРЛС (app/llm/med_qa.py); тексты ниже — обрамление и
|
||||
# запасные варианты, если выдержки нет.
|
||||
|
||||
status: "черновик, утверждает психиатр (#8)"
|
||||
|
||||
reminder:
|
||||
# {drug}, {dose} — из назначения врача (МНН и дозировка как в назначении, без изменений).
|
||||
text: "Время принять {drug} {dose}. Отметьте, пожалуйста, как получилось."
|
||||
text_no_drug: "Время принять препарат, назначенный врачом. Отметьте, пожалуйста, как получилось."
|
||||
taken: "Принял(а)"
|
||||
skip: "Пропущу"
|
||||
side_effects: "Есть побочные эффекты"
|
||||
|
||||
taken_ack: "Отлично, отметил приём. Спасибо!"
|
||||
# Кнопка от напоминания, которое уже неактуально (например, вчерашнего).
|
||||
stale: "Это напоминание уже неактуально. Если хотите отметить самочувствие — нажмите «Как я сегодня»."
|
||||
|
||||
skip:
|
||||
question: "Понял, отметил. Подскажите, что помешало? Это поможет вашему врачу."
|
||||
reasons:
|
||||
forgot: "Забыл(а)"
|
||||
side_effects: "Из-за побочных эффектов"
|
||||
quit: "Решил(а) бросить"
|
||||
other: "Другое"
|
||||
ack:
|
||||
forgot: "Понял, бывает. Завтра напомню в обычное время."
|
||||
quit: "Спасибо, что честно сказали — это важно, и решать это лучше вместе с врачом."
|
||||
other: "Понял, отметил. Если захотите рассказать подробнее — просто напишите."
|
||||
|
||||
side_effects:
|
||||
question: "Что беспокоит? Выберите вариант или «Другое», чтобы описать своими словами."
|
||||
options:
|
||||
nausea: "Тошнота"
|
||||
insomnia: "Бессонница"
|
||||
headache: "Головная боль"
|
||||
dizziness: "Головокружение"
|
||||
agitation: "Беспокойство"
|
||||
other: "Другое"
|
||||
# Короткий вопрос к инструкции препарата (med_qa, раздел «побочные эффекты») для выбранного эффекта.
|
||||
questions:
|
||||
nausea: "тошнота после приёма, когда пройдёт"
|
||||
insomnia: "бессонница, нарушения сна"
|
||||
headache: "головная боль"
|
||||
dizziness: "головокружение"
|
||||
agitation: "беспокойство, тревога"
|
||||
describe: "Опишите, пожалуйста, коротко, что вы заметили."
|
||||
ack: "Спасибо, отметил. Ваш врач увидит это в вашей карточке."
|
||||
# Тот же эффект был и вчера: инструкцию второй раз не повторяем.
|
||||
repeat: "Понимаю, это неприятно. Я отмечаю это каждый день, и ваш врач увидит, если не проходит."
|
||||
ask_dose: "А препарат сегодня приняли?"
|
||||
dose_yes: "Да, принял(а)"
|
||||
dose_no: "Нет"
|
||||
|
||||
# Ежедневный чек-ин: если сегодня ещё нет отметки о приёме.
|
||||
checkin:
|
||||
question: "Сегодня приняли {drug}?"
|
||||
question_no_drug: "Сегодня приняли назначенный препарат?"
|
||||
"yes": "Да"
|
||||
"no": "Нет"
|
||||
later: "Ещё нет"
|
||||
|
||||
question:
|
||||
prompt: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — подскажу обратиться к врачу."
|
||||
no_prescription: "Я не нашёл в карточке назначенного препарата. Пожалуйста, уточните вопрос у вашего врача."
|
||||
|
||||
# Ответы на действие правила med_info. Ответ = lead + выдержка из инструкции (med_qa по разделу section,
|
||||
# вопрос question) + notice. Если выдержки нет — lead + fallback + notice.
|
||||
med_info:
|
||||
delayed_effect:
|
||||
section: onset
|
||||
question: "когда начнёт действовать препарат, нет эффекта, только хуже"
|
||||
lead: "Понимаю, что сейчас тяжело."
|
||||
fallback: "Эффект таких препаратов обычно наступает не сразу, а через 2–4 недели регулярного приёма. Пожалуйста, не прекращайте приём самостоятельно."
|
||||
notice: "Я сообщил вашему врачу — он свяжется с вами, чтобы обсудить, как вы себя чувствуете."
|
||||
alcohol_interaction:
|
||||
section: alcohol
|
||||
question: "можно ли алкоголь вместе с препаратом, сон"
|
||||
lead: "Спасибо, что честно рассказали."
|
||||
fallback: "В инструкции к препарату есть предупреждение о совместном приёме с алкоголем, а алкоголь сам по себе ухудшает сон."
|
||||
notice: "Я передал это вашему врачу."
|
||||
nausea:
|
||||
section: side_effects
|
||||
question: "тошнота после приёма, когда пройдёт"
|
||||
lead: ""
|
||||
fallback: "Тошнота часто встречается в первые дни приёма и обычно проходит сама."
|
||||
notice: "Если станет хуже — напишите мне, я передам врачу."
|
||||
side_effects:
|
||||
section: side_effects
|
||||
question: "побочные эффекты в начале приёма"
|
||||
lead: ""
|
||||
fallback: "Спасибо, что сообщили о самочувствии. Я отмечу это для вашего врача."
|
||||
notice: "Если станет хуже — напишите мне, я передам врачу."
|
||||
@@ -21,6 +21,8 @@ phrases:
|
||||
doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами."
|
||||
# med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя.
|
||||
med_answer: "{excerpt}"
|
||||
# RAG (#60): базовый смысл = выдержка из клинических рекомендаций; LLM только упрощает её, ничего не добавляя.
|
||||
rag_answer: "{excerpt}"
|
||||
|
||||
# Шаблон саммари для врача (только по структурированным данным).
|
||||
summary:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Тексты ответов из базы знаний (RAG, #60). Зона clinical вычитывает формулировки.
|
||||
# Сам ответ — ТОЛЬКО предложения из клинических рекомендаций (экстрактивно); здесь — обрамление.
|
||||
# Правила: без советов по дозам, без рекомендаций препаратов, решение о лечении — за врачом.
|
||||
|
||||
patient:
|
||||
# Ничего не нашлось / вопрос о дозе — честно говорим и отправляем к врачу.
|
||||
fallback: "В клинических рекомендациях я не нашёл ответа на этот вопрос. Уточните, пожалуйста, у вашего врача."
|
||||
# Пациент должен понимать, что это выдержка из документа для врачей, а не совет бота.
|
||||
intro: "В клинических рекомендациях Минздрава сказано:"
|
||||
sources_label: "Источник"
|
||||
# Всегда в конце (не проходит через LLM).
|
||||
doctor_note: "Это общая информация из документа для врачей. Что подходит именно вам, решает ваш лечащий врач."
|
||||
|
||||
doctor:
|
||||
fallback: "В базе знаний (КР ПТСР 2023, КР Депрессия 2024) нет фрагмента по этому запросу."
|
||||
intro: "По клиническим рекомендациям:"
|
||||
sources_label: "Источники"
|
||||
doctor_note: ""
|
||||
|
||||
team:
|
||||
fallback: "В документах команды нет фрагмента по этому запросу."
|
||||
intro: "По документам команды:"
|
||||
sources_label: "Источники"
|
||||
doctor_note: ""
|
||||
@@ -0,0 +1,120 @@
|
||||
# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are
|
||||
# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для
|
||||
# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result.
|
||||
disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации."
|
||||
|
||||
intake:
|
||||
ok:
|
||||
title: "Признаков депрессии и ПТСР не выявлено"
|
||||
text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше."
|
||||
attention:
|
||||
title: "Есть признаки, которые стоит обсудить со специалистом"
|
||||
text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше."
|
||||
|
||||
findings:
|
||||
depression_suspected:
|
||||
title: "Предположение депрессии"
|
||||
text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике."
|
||||
depression_mild:
|
||||
title: "Лёгкие симптомы депрессии"
|
||||
text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить."
|
||||
ptsd_suspected:
|
||||
title: "Склонность к ПТСР"
|
||||
text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике."
|
||||
ptsd_signs:
|
||||
title: "Отдельные признаки посттравматического стресса"
|
||||
text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР."
|
||||
alcohol_risky:
|
||||
title: "Рискованное употребление алкоголя"
|
||||
text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом."
|
||||
alcohol_high:
|
||||
title: "Высокий риск зависимости от алкоголя"
|
||||
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
|
||||
suicide_low:
|
||||
title: "Тяжёлые мысли"
|
||||
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия."
|
||||
tbi:
|
||||
title: "Контузия или ЧМТ"
|
||||
text: "Рекомендуем консультацию невролога."
|
||||
|
||||
routes:
|
||||
urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра."
|
||||
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
|
||||
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
|
||||
psychologist: "Рекомендуем консультацию психолога."
|
||||
self_help: "Достаточно самопомощи и короткого ежедневного дневника."
|
||||
|
||||
modules:
|
||||
title: "Что дальше"
|
||||
diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию."
|
||||
names:
|
||||
depression: "«Депрессия»"
|
||||
veteran: "«ПТСР»"
|
||||
with: ", плюс модуль {names}"
|
||||
|
||||
scales:
|
||||
phq9:
|
||||
title: "Опросник PHQ-9 (депрессия)"
|
||||
levels:
|
||||
minimal: "минимальные симптомы"
|
||||
mild: "лёгкие симптомы"
|
||||
moderate: "умеренные симптомы"
|
||||
moderately_severe: "умеренно тяжёлые симптомы"
|
||||
severe: "тяжёлые симптомы"
|
||||
pcl5:
|
||||
title: "Опросник PCL-5 (ПТСР)"
|
||||
levels:
|
||||
below: "ниже порога вероятного ПТСР"
|
||||
probable: "выше порога вероятного ПТСР"
|
||||
rus_audit_s:
|
||||
title: "Опросник RUS-AUDIT-S (алкоголь)"
|
||||
levels:
|
||||
zone_1: "относительно низкий риск"
|
||||
zone_2: "опасное употребление"
|
||||
zone_3: "пагубное употребление"
|
||||
zone_4: "риск зависимости"
|
||||
unknown: "нельзя оценить без указания пола"
|
||||
score: "{title}: {total} — {level}."
|
||||
change:
|
||||
remission: "Это ремиссия по шкале — отличный результат."
|
||||
response: "По сравнению с началом наблюдения — ответ на лечение."
|
||||
improved: "По сравнению с началом наблюдения — клинически значимое улучшение."
|
||||
stable: "По сравнению с началом наблюдения существенных изменений нет."
|
||||
worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план."
|
||||
|
||||
diary:
|
||||
saved: "Спасибо, дневник за {part} сохранён."
|
||||
parts:
|
||||
morning: "утро"
|
||||
evening: "вечер"
|
||||
flags:
|
||||
phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока."
|
||||
phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока."
|
||||
pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока."
|
||||
sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке."
|
||||
alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку."
|
||||
rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе."
|
||||
medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему."
|
||||
adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом."
|
||||
side_effects_review: "Вы отметили побочные эффекты — врач получит отметку."
|
||||
|
||||
cssrs:
|
||||
none: "Спасибо за ответы."
|
||||
low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело."
|
||||
|
||||
kinds:
|
||||
intake: "Первичный опрос"
|
||||
diary_morning: "Дневник — утро"
|
||||
diary_evening: "Дневник — вечер"
|
||||
cssrs_since_contact: "Вопросы о безопасности"
|
||||
phq9: "PHQ-9 по расписанию"
|
||||
pcl5: "PCL-5 по расписанию"
|
||||
rus_audit_s: "Алкоголь по расписанию"
|
||||
diary: "Дневник"
|
||||
status:
|
||||
complete: "пройден"
|
||||
review_required: "остановлен для оценки специалиста"
|
||||
interrupted: "прерван: открыта экстренная помощь"
|
||||
active: "в процессе"
|
||||
paused: "на паузе"
|
||||
cancelled: "отменён"
|
||||
@@ -0,0 +1,62 @@
|
||||
# Тексты ежедневного чек-ина и плановых шкал (issue #37, ADR-002, CLINICAL_MODULES §1–2).
|
||||
# Ядро (настроение + приём) задаётся всем; вопрос про сон и кошмары — модулю veteran; алкоголь — см. settings.
|
||||
# СТАТУС: черновик, утверждает психиатр (#8).
|
||||
#
|
||||
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; пациент не видит баллов шкал;
|
||||
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом).
|
||||
|
||||
status: "черновик, утверждает психиатр (#8)"
|
||||
|
||||
settings:
|
||||
# Модули, которым задаётся вопрос про сон и кошмары (modules.yaml: daily_question = sleep_nightmares).
|
||||
sleep_modules: [veteran]
|
||||
# Ежедневный вопрос об алкоголе: модули, где он задаётся всегда (у ветеранов алкоголь — частый способ
|
||||
# «заглушить» бессонницу и фактор риска, CLINICAL_MODULES §1); остальным — при положительном AUDIT-C
|
||||
# (порог берётся из правила auditc_positive в config/rules.yaml, ADR-002).
|
||||
alcohol_daily_modules: [veteran]
|
||||
# Плановые шкалы, перед которыми показывается дружелюбное вступление (см. scale_intros).
|
||||
intro_scales: [pcl5, phq9, dar5, auditc]
|
||||
|
||||
checkin:
|
||||
start: "Добрый день! Короткая отметка о самочувствии — это займёт меньше минуты."
|
||||
mood:
|
||||
question: "Как ваше настроение сегодня по шкале от 0 до 10? 0 — очень плохо, 10 — отлично."
|
||||
sleep:
|
||||
question: "Сколько часов удалось поспать прошлой ночью?"
|
||||
options:
|
||||
"<4": "Меньше 4 ч"
|
||||
"4-6": "4–6 ч"
|
||||
"6-8": "6–8 ч"
|
||||
">8": "Больше 8 ч"
|
||||
nightmares:
|
||||
question: "Снились ли кошмары или тяжёлые сны?"
|
||||
"yes": "Да"
|
||||
"no": "Нет"
|
||||
alcohol:
|
||||
question: "Выпивали ли вы алкоголь за последние сутки?"
|
||||
clarify: "Вы упомянули алкоголь. Сколько примерно выпили за последние сутки?"
|
||||
options:
|
||||
none: "Не пил(а)"
|
||||
"1-2": "1–2 порции"
|
||||
more: "Больше"
|
||||
text:
|
||||
question: "Как вы? Можно пару слов — что было за день, что беспокоит."
|
||||
skip: "Пропустить"
|
||||
buttons_hint: "Выберите, пожалуйста, вариант кнопкой."
|
||||
done: "Спасибо, записал. Если что-то изменится — пишите в любое время."
|
||||
already_done: "Сегодня вы уже отмечались, спасибо! Если хотите что-то добавить — просто напишите."
|
||||
|
||||
visit:
|
||||
# Нажата кнопка «Записаться к врачу раньше» (действие правила suggest_visit). Онлайн-записи пока нет —
|
||||
# просьба передаётся врачу как факт в карточке.
|
||||
requested: "Передал врачу просьбу о более раннем визите. С вами свяжутся, чтобы договориться о времени. Если станет хуже до визита — пишите мне или звоните 112."
|
||||
|
||||
# Дружелюбное вступление перед шкалой (параметр intro у start_scale). Пациент не видит баллов.
|
||||
scale_intros:
|
||||
scheduled:
|
||||
pcl5: "Раз в две недели я прошу ответить на вопросы о том, как беспокоят тяжёлые воспоминания и их последствия. Это займёт 3–4 минуты. Так ваш врач увидит, что меняется со временем."
|
||||
phq9: "Пора для короткого опроса о настроении и силах за последние две недели — 9 вопросов. Отвечайте так, как есть, правильных ответов нет."
|
||||
dar5: "Несколько вопросов о раздражительности и вспышках гнева. После службы это бывает у многих, и врачу важно видеть, как это меняется."
|
||||
auditc: "Три коротких вопроса об алкоголе. Отвечайте как есть: это нужно врачу, чтобы лечение было безопасным."
|
||||
unscheduled:
|
||||
phq9: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас."
|
||||
@@ -0,0 +1,92 @@
|
||||
# Тексты стартового онбординга ветерана (issue #36): вступление, вопрос о травматическом опыте, пол для AUDIT-C,
|
||||
# вопрос о доступе к оружию, блок безопасного хранения, итог, FAQ.
|
||||
# СТАТУС: черновик, утверждает психиатр (#8). Ответы FAQ — формулировку проверяет юрист.
|
||||
#
|
||||
# Правила (проверяются тестом tests/test_dialog_veteran_onboarding.py):
|
||||
# * тон — как у сослуживца: на «вы», коротко, без жалости и без нравоучений;
|
||||
# * без слов «диагноз», «расстройство», «психолог», «психиатр», «болезнь», «псих»;
|
||||
# * о сервисе — никогда «учёт», «контроль», «надзор» (страх пациента можно процитировать только в подписи
|
||||
# кнопки FAQ — так человек узнаёт свой вопрос);
|
||||
# * пациент не видит баллов и названий шкал.
|
||||
|
||||
status: "черновик, утверждает психиатр (#8)"
|
||||
|
||||
settings:
|
||||
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
|
||||
# Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию.
|
||||
weapon_risk_flags: [phq9_item9_positive, cssrs_high, cssrs_moderate, auditc_positive]
|
||||
|
||||
intro:
|
||||
text: |-
|
||||
Рад, что вы здесь. Пара слов, прежде чем начнём.
|
||||
|
||||
После возвращения многие замечают одно и то же: сон сбит, в голову лезет прошлое, тело всё ещё «на дежурстве», раздражает то, что раньше не трогало, тянет выпить, чтобы отпустило. Это частая реакция на то, через что вы прошли, а не слабость — и с этим реально работают.
|
||||
|
||||
Сейчас будет несколько коротких вопросов про сон, напряжение, настроение и алкоголь — минут пять, почти всё кнопками. Ответы увидит только ваш лечащий врач: так он сможет помогать точнее.
|
||||
go: "Начнём"
|
||||
repeat: "Когда будете готовы — нажмите «Начнём». Если есть вопросы, ответы на частые — кнопками ниже."
|
||||
resume: "Давайте продолжим с того места, где остановились. Осталось немного."
|
||||
|
||||
buttons_hint: "Пожалуйста, выберите вариант кнопкой ниже."
|
||||
|
||||
exposure:
|
||||
question: |-
|
||||
Бывало ли с вами событие, которое сильно напугало, потрясло или было по-настоящему опасным для жизни — на службе, в бою или в другое время?
|
||||
Подробности рассказывать не нужно.
|
||||
answer_yes: "Да"
|
||||
answer_no: "Нет"
|
||||
decline: "Не хочу отвечать"
|
||||
ack_yes: "Понял. Несколько вопросов о том, как это даёт о себе знать сейчас."
|
||||
ack_other: "Хорошо, идём дальше."
|
||||
|
||||
sex:
|
||||
question: "Один уточняющий вопрос: ваш пол? Он нужен только для того, чтобы правильно учесть ответы про алкоголь — нормы для мужчин и женщин разные."
|
||||
male: "Мужской"
|
||||
female: "Женский"
|
||||
decline: "Не хочу отвечать"
|
||||
|
||||
weapon:
|
||||
question: |-
|
||||
Последний вопрос, он про безопасность, а не про проверку: есть ли у вас сейчас доступ к оружию — табельному, охотничьему, наградному или любому другому?
|
||||
no_access: "Нет"
|
||||
self: "Есть, хранится у меня"
|
||||
others_or_safe: "Есть, у других / в сейфе"
|
||||
decline: "Не хочу отвечать"
|
||||
ack: "Спасибо, что ответили."
|
||||
# Блок безопасного хранения — фиксированный текст, показывается только при доступе к оружию И сигнале риска.
|
||||
safety_block_self: |-
|
||||
Одна просьба — без нравоучений. Пока вам тяжело, пусть оружие и патроны побудут у того, кому вы доверяете: у брата, друга, в сейфе, ключ от которого у другого человека. Дело не в недоверии к вам. В трудную минуту важно, чтобы между мыслью и действием было время — это одна из самых надёжных мер защиты, и так делают многие.
|
||||
Это можно спокойно обсудить с врачом на приёме.
|
||||
safety_block_stored: |-
|
||||
Хорошо, что оружие хранится не под рукой. Пусть пока так и остаётся: ключ или код — у человека, которому вы доверяете, а патроны — отдельно. В трудную минуту важно, чтобы между мыслью и действием было время.
|
||||
Это можно спокойно обсудить с врачом на приёме.
|
||||
safety_block_unknown: |-
|
||||
Понимаю, что не хочется об этом говорить. Просто на всякий случай: если дома есть оружие или большой запас лекарств, пусть пока они побудут у человека, которому вы доверяете. В трудную минуту важно, чтобы между мыслью и действием было время.
|
||||
|
||||
result:
|
||||
intro: "Спасибо, что ответили на всё. Коротко, что видно по ответам:"
|
||||
calm: "Сейчас серьёзных трудностей по ответам не видно. Хорошо — будем держать руку на пульсе."
|
||||
load: "Нагрузка сейчас заметная. Так бывает у многих после возвращения, и это то, с чем врач может помочь."
|
||||
ptsd: "• Прошлое пока не отпускает: напряжение, воспоминания, сон."
|
||||
mood: "• Настроение и силы ниже, чем хотелось бы."
|
||||
alcohol: "• Алкоголь — стоит обсудить с врачом, особенно если назначат препараты: они с алкоголем плохо сочетаются."
|
||||
next: "Ваши ответы уже у лечащего врача — он обсудит их с вами на приёме. А я буду на связи каждый день с одним коротким вопросом и напоминанием о препарате."
|
||||
|
||||
faq:
|
||||
who:
|
||||
button: "Кто увидит мои ответы?"
|
||||
# формулировку проверяет юрист
|
||||
text: |-
|
||||
Только ваш лечащий врач — тот, кто дал вам код подключения. Перед автоматической обработкой из сообщений удаляются имена, телефоны и другие личные данные. Данные хранятся в России.
|
||||
Отозвать согласие и остановить обработку можно в любой момент — кнопкой «Отозвать согласие» или командой /revoke.
|
||||
unit:
|
||||
button: "Это попадёт на учёт / в часть?"
|
||||
# формулировку проверяет юрист
|
||||
text: |-
|
||||
Нет. Это добровольная консультативная помощь между визитами к врачу. Ваши ответы не передаются в часть, военкомат, работодателю или кому-то ещё без вашего согласия — кроме случаев, прямо предусмотренных законом. Отказ от помощника никак не влияет на ваше лечение.
|
||||
licences:
|
||||
button: "Заберут права на оружие или вождение?"
|
||||
# формулировку проверяет юрист
|
||||
text: |-
|
||||
Помощник не выдаёт заключений и не отправляет ваши ответы ни в ГИБДД, ни в Росгвардию, ни на медкомиссии. Допуск к оружию и вождению решает отдельная медицинская комиссия по своим правилам, установленным законом.
|
||||
Как именно это связано с вашим лечением — спросите лечащего врача прямо. Это нормальный вопрос, его задают многие, и честный ответ лучше, чем откладывать помощь из-за неизвестности.
|
||||
@@ -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)
|
||||
+116
-11
@@ -1,18 +1,123 @@
|
||||
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API."""
|
||||
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
|
||||
|
||||
import os
|
||||
Entry point: `streamlit run dashboard/app.py`. Pages are discovered from `dashboard/pages/*.py` — adding a page
|
||||
is adding a file, app.py never needs editing:
|
||||
|
||||
import httpx
|
||||
* file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
|
||||
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"`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
|
||||
from dashboard import theme
|
||||
from dashboard.components import live_alerts, state, ui
|
||||
|
||||
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
|
||||
st.title("Кабинет врача")
|
||||
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("Прототип на синтетических данных")
|
||||
|
||||
try:
|
||||
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
|
||||
except httpx.HTTPError:
|
||||
status = "недоступен"
|
||||
st.write(f"API: **{status}**")
|
||||
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).
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user