Compare commits

..
Author SHA1 Message Date
Pavel Eliseev c01bfc90df feat(dashboard): replace home with approved minimal design
CI / ruff · pytest (pull_request) Successful in 2m1s
Refs #73
2026-10-04 14:54:14 +03:00
Eleboba a2b1b2e7a5 Merge pull request 'feat(bot): чат-агент — RAG + карта ЕМИАС + проверка препаратов' (#72) from feat/chat-agent into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 14:41:55 +03:00
ElebobaandClaude Opus 5.5 6dda5f9b30 feat(bot): tool-using chat agent over RAG, ЕМИАС record and drug-safety reference
CI / ruff · pytest (pull_request) Successful in 2m10s
Free-text treatment and medication questions go to an LLM agent with read-only tools bound to the current
patient: guideline search (RAG, doses stripped), ЕМИАС record mock (diagnoses, comorbidities, intolerances,
other specialists' drugs, current prescription), drug info (composition, contraindications, interactions)
and a deterministic patient-specific check. Providers: OpenRouter (temporary, synthetic data only) and an
offline mock; GigaChat to follow. Scrubbed input only, forbidden-output filter, any failure falls back to
the existing extractive RAG / instruction answers. Off unless OPENROUTER_API_KEY or CHAT_AGENT=mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:39:16 +03:00
Eleboba 2f1f8a8f24 Merge pull request 'feat(dashboard): кабинет врача — красные зоны, карточка, живые алерты, подключение пациента' (#70) from feat/16-doctor-dashboard into dev
CI / ruff · pytest (push) Successful in 2m2s
2026-10-04 12:55:23 +03:00
Eleboba 497fb551f5 Merge pull request 'feat(dashboard): демо-консоль, проигрывание сценария, сплит-экран' (#69) from feat/17-demo-console into dev
CI / ruff · pytest (push) Successful in 2m18s
2026-10-04 12:53:14 +03:00
Eleboba 8e62722ec0 Merge pull request 'feat(backend): seed демо — Андрей (дни 0–13) и 7 пациентов кабинета' (#68) from feat/18-seed into dev
CI / ruff · pytest (push) Successful in 1m50s
2026-10-04 12:53:11 +03:00
ElebobaandClaude Opus 5.5 2582c73d12 feat(dashboard): doctor dashboard with red zones, patient card, live alerts and patient connect
CI / ruff · pytest (pull_request) Successful in 2m1s
- Patient list sorted red -> yellow -> grey -> green with KPI row, trend arrows,
  7-day adherence bars, discontinuation-risk badge with factor tooltip.
- Patient card: scales with severity bands from config/scales (unscheduled marked),
  mood/sleep/alcohol small multiples, dose heat-strip, alerts feed with ack,
  safety plan, AI summary with disclaimer.
- Live alerts: st.fragment(run_every=2.5s) polling, toast + pinned acute banner on
  every page; full rerun on new alert only on doctor pages.
- Connect form -> POST /patients/connect -> code, QR, deep link.
- Thin httpx ApiClient over the frozen schemas with graceful "API недоступен".
- app.py discovers dashboard/pages/*.py automatically (st.navigation); demo pages
  of #17 plug in by adding a file.

Refs #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:51:07 +03:00
ElebobaandClaude Opus 5.5 8d12aa3d08 feat(dashboard): console fallbacks for the real API (#12)
CI / ruff · pytest (pull_request) Successful in 1m37s
- personas degrade: /demo/personas → doctor's patients (hero first) → env token
- scenario file guessed from the transliterated first name (andrey.jsonl), like the backend
- «Подключить демо-пациента»: connects the synthetic hero via /patients/connect when no seed
- connect code prefilled from ?code= of the console link

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:42 +03:00
ElebobaandClaude Opus 5.5 bc91873a13 feat(backend): demo seed — veteran Andrey (day 0..13) and 7 cabinet patients
CI / ruff · pytest (pull_request) Successful in 1m44s
- scripts/seed_demo.py: doctor doc-smirnova (on call), Andrey day 0 (connect code,
  no consent) or day N with history; 7 patients with seeded-random histories.
  Alerts and colours come from the real rules evaluated day by day.
- --reset drops/recreates both DBs and zeroes the demo clock; seed keeps the
  persisted clock offset so dates match the running app.
- data/synthetic/andrey.jsonl: console scenario for days 0..13 generated from
  config button labels (--write-scenario).
- tests: idempotency, modes, colours, scenario format/sync and a full replay of
  the scenario through the real engine, scheduler and monitoring.

Refs #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:50:30 +03:00
ElebobaandClaude Opus 5.5 260422138a feat(dashboard): demo console with web chat, demo clock, scenario playback and split screen
- pages/2_Демо-консоль.py: persona selector, virtual clock bar (+1 h, to 08:00/20:00,
  next day, reset with confirm), fallback patient web chat (buttons from Reply.buttons,
  kind styling, outbox drained after every action and on a 2 s fragment poll), scenario panel
- pages/3_Сплит-экран.py: patient chat or PATIENT_WEB_URL iframe on the left, compact doctor
  panel (patients, live alerts, red banner on a new acute alert) on the right
- console/playback.py: data/synthetic/<persona>.jsonl runner (advance_to/press/text/
  expect_color/start), date chaining between days, pause on failure with retry/skip/manual
- console/api.py: own HTTP client over the frozen API contract, ApiError only

Refs #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:46:14 +03:00
Eleboba 178cab5b1b Merge pull request 'feat(integrations): адаптеры МИС, код подключения, выдержки ГРЛС, уведомления врача' (#67) from feat/14-integrations into dev
CI / ruff · pytest (push) Successful in 1m29s
2026-10-04 12:38:18 +03:00
Eleboba c4eab1ef66 Merge pull request 'feat(backend): API этапа 1 на реальных данных (#12)' (#66) from feat/12-api into dev
CI / ruff · pytest (push) Successful in 1m26s
2026-10-04 12:38:14 +03:00
ElebobaandClaude Opus 5.5 9362eb6ecc feat(integrations): demo adapters for МИС, connect-code identity, ГРЛС excerpts, doctor notifier
CI / ruff · pytest (pull_request) Successful in 1m26s
- setup() at package import (idempotent, replaces only base.py defaults): MisMock, ConnectCodeIdentity,
  GrlsExcerpts, CompositeNotifier; outbox stays MemoryOutbox
- MisMock: clinical DB prescriptions, fallback data/meds/mis_prescriptions.json (synthetic, by token)
- ConnectCodeIdentity: one-time connect code via identity_repo, 15 min grace for the double check in one
  connect flow (Telegram /start + engine `new` state)
- GrlsExcerpts: synthetic paraphrased instruction excerpts for sertraline, paroxetine, escitalopram,
  fluoxetine, mirtazapine, prazosin
- CompositeNotifier: in-memory feed (recent(since_id)) + Telegram doctor chat, PII-free, never raises
- test_llm_med_qa: default-registry test now patches NoDrugInfo explicitly

Refs #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:36:07 +03:00
ElebobaandClaude Opus 5.5 25728c4b31 feat(backend): API stage 1 on real data instead of fixtures
CI / ruff · pytest (pull_request) Successful in 1m25s
Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo
controls now read the identity and clinical DBs. Rule-based discontinuation
risk index, trend, adherence; every doctor read is audited.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:40 +03:00
Eleboba 53818ac76c Merge pull request 'feat(bot): ответы на вопросы о лечении из базы знаний КР (#64)' (#65) from feat/64-rag-dialog into dev
CI / ruff · pytest (push) Successful in 1m32s
2026-10-04 11:29:49 +03:00
ElebobaandClaude Opus 5.5 b72af71e66 feat(bot): answer treatment questions from the clinical-guideline knowledge base
CI / ruff · pytest (pull_request) Successful in 1m30s
- menu button «Вопрос о лечении» -> kb.question -> rag.answer (KR quote + source)
  or «уточните у врача»; dose questions get a fixed doctor redirect
- free text in idle that looks like a question (no check-in signals, not a drug
  question) -> knowledge base; no answer -> the usual support phrase
- routing order unchanged: consent/safety first, side effects/quit/alcohol -> check-in,
  own drug -> med_qa (instruction only, SPEC §7 p.5)
- question cues, small talk and texts in config/texts/knowledge.yaml; scrub placeholders
  before "что такое X" no longer hide the definition question

Refs #64

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:27:42 +03:00
Eleboba 7272203178 Merge pull request 'fix(llm): RAG — страницы в цитатах, метки scrub, выбор КР по заболеванию (#62)' (#63) from fix/62-rag-fixes into dev
CI / ruff · pytest (push) Successful in 1m21s
2026-10-04 11:14:20 +03:00
ElebobaandClaude Opus 5.5 fe29ebd629 fix(llm): RAG citation pages, scrub placeholders, disease-aware document choice
CI / ruff · pytest (pull_request) Successful in 1m2s
- converter marks a paragraph glued across a page break with ⟦стр. N⟧; the chunker
  strips the marker and extends the chunk's page range (wrong-page citations 24 -> 3,
  the rest are scale-form table rows split by a page)
- [NAME]/[PHONE] placeholders from privacy.scrub() no longer count as unknown words
  (a scrubbed question used to fall below the coverage gate)
- a question naming one disease boosts its guideline in retrieval and passage choice
  ("ТМС при депрессии" was answered from the PTSD guideline); "антидепрессант" is not a cue

Refs #62

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:31:59 +03:00
Eleboba 6c212d560c Merge pull request 'feat(llm): RAG — база знаний по КР с цитатами (#60)' (#61) from feat/60-rag into dev
CI / ruff · pytest (push) Successful in 1m9s
2026-10-04 04:14:52 +03:00
ElebobaandClaude Opus 5.5 6a77831603 feat(llm): RAG knowledge base over clinical guidelines with cited extractive answers
CI / ruff · pytest (pull_request) Successful in 1m30s
- docs/source/rag: PDF sources (KR PTSD 2023, KR depression 2024, meds module spec),
  pdf->txt converter (tables as markdown, page markers, hand-transcribed figures),
  word-level check against pdftotext; fixes HAM-D/MADRS fake headers and split rows
- config/rag: corpus txt + sources.yaml (documents, audiences, retrieval params, synonyms)
- app/llm/rag: Snowball stemmer, section-aware chunker with page citations,
  BM25 + synonym expansion + densest-passage reranking, extractive answer()
  with coverage gate, patient dose/prescribing filter, optional LLM rephrasing
  rejected when it adds content; CLI python -m app.llm.rag
- tests: gold retrieval set (hit@3 >= 15/18), off-topic, safety, faithfulness
- ADR-011

Refs #60

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:11:06 +03:00
Eleboba 66986255e2 Merge pull request 'feat(backend): шедулер на демо-часах — дозы, missed, чек-ины, шкалы, молчание' (#59) from feat/13-scheduler into dev
CI / ruff · pytest (push) Successful in 1m9s
Reviewed-on: #59
2026-10-04 03:29:25 +03:00
96 changed files with 17512 additions and 52 deletions
+5
View File
@@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS=
GIGACHAT_SCOPE=GIGACHAT_API_PERS GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))" # --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY= PSEUDO_KEY=
+18
View File
@@ -0,0 +1,18 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme]
base = "light"
primaryColor = "#2A78D6"
backgroundColor = "#F6F7F9"
secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24"
borderColor = "#E3E6EA"
baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004).
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap, sans-serif"
headingFontWeights = 700
[client]
toolbarMode = "minimal"
[browser]
gatherUsageStats = false
+89
View File
@@ -0,0 +1,89 @@
"""Demo-console helpers (#12, ADR-005): scripted personas, reset, connect links, outbox access.
Synthetic data only. Scenario files live in `data/synthetic/<slug>.jsonl` (#18); a patient is a scripted persona
when the transliterated first word of the display name equals the slug ("Андрей С." → `andrey.jsonl`).
"""
import inspect
import logging
from importlib import import_module, util
from pathlib import Path
from app.config import get_settings
from app.integrations.base import MemoryOutbox, Outbox, integrations
log = logging.getLogger(__name__)
SEED_MODULE = "scripts.seed_demo"
CONSOLE_PATH = "/console"
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def scenario_files(data_dir: Path | None = None) -> dict[str, str]:
"""slug → scenario path relative to the project root, e.g. {"andrey": "data/synthetic/andrey.jsonl"}."""
root = data_dir or get_settings().data_dir
folder = root / "synthetic"
if not folder.is_dir():
return {}
return {f.stem: f"data/synthetic/{f.name}" for f in sorted(folder.glob("*.jsonl"))}
async def bot_username() -> str | None:
"""Username of the running Telegram bot (deep links); None when the bot is not started (no TG_BOT_TOKEN)."""
from app.channels import telegram
bot = getattr(telegram, "_bot", None)
if bot is None:
return None
try:
me = await bot.me() # aiogram caches getMe
except Exception as exc: # noqa: BLE001 — network / auth errors: fall back to the web console link
log.warning("bot username unavailable error=%s", type(exc).__name__)
return None
return me.username or None
async def deep_link(code: str) -> str:
username = await bot_username()
return f"https://t.me/{username}?start={code}" if username else f"{CONSOLE_PATH}?code={code}"
def memory_outbox() -> MemoryOutbox | None:
"""The in-memory outbox polled by the web console, also when wrapped (Telegram keeps it as `fallback`)."""
box: Outbox | None = integrations.outbox
for _ in range(5):
if isinstance(box, MemoryOutbox):
return box
box = getattr(box, "fallback", None)
return None
async def reset_demo() -> bool:
"""Clock reset is done by the caller; re-seed via `scripts.seed_demo.reset()` (#18) when it exists."""
box = memory_outbox()
if box is not None:
box._q.clear()
try:
spec = util.find_spec(SEED_MODULE)
except ModuleNotFoundError: # no `scripts` package on the path (e.g. installed app without scripts/)
spec = None
if spec is None:
return False
reset = getattr(import_module(SEED_MODULE), "reset", None)
if reset is None:
return False
result = reset()
if inspect.isawaitable(result):
await result
return True
+368
View File
@@ -0,0 +1,368 @@
"""Doctor dashboard read models (#12): clinical DB rows → frozen API schemas (`app/api/schemas.py`).
Everything shown to the doctor is derived from stored structured data — no LLM decides colours, trends or the
discontinuation risk (ADR-007). Time comes in as `now` (from `app.core.clock`, ADR-005).
Identity data (guardrail #5): the API is the doctor's display boundary, so it reads exactly two identity fields —
`display_name` (minimal identification for the treating doctor) and `birth_year` (age). SNILS, phone and chat
id are never selected into a response model.
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any, Literal, cast, get_args
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import (
AlertOut,
Color,
DiscontinuationRisk,
DoseOut,
LevelName,
Metric,
Module,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.core.rules import color, patient_status
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert, DailyCheckin, Dose, PatientProfile, Prescription, SafetyPlan, ScaleResult
from app.db.identity import Patient
from app.integrations.base import Prescription as PrescriptionOut
COLOR_ORDER: dict[str, int] = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
_MODULES = frozenset(get_args(Module))
_METRICS = frozenset(get_args(Metric))
_SCALE_LABELS = {"phq9": "PHQ-9", "pcl5": "PCL-5", "gad7": "GAD-7", "auditc": "AUDIT-C", "dar5": "DAR-5"}
# Daily check-in buckets → numbers for charts. Sleep: bucket midpoints in hours ("<4" plotted as 3, ">8" as 9).
SLEEP_HOURS = {"<4": 3.0, "4-6": 5.0, "6-8": 7.0, ">8": 9.0}
# Alcohol: 0 = none, 1 = 1–2 drinks, 2 = more (same coding as app/llm/summary.py expects).
ALCOHOL_LEVEL = {"none": 0.0, "1-2": 1.0, "more": 2.0}
# Trend: the last two complete results of one scale. A change smaller than about half of the minimal clinically
# important difference (PHQ-9: 5 points, PCL-5: 10 points) is reported as "flat" so noise does not flip arrows.
TREND_MIN_CHANGE = {"phq9": 3, "pcl5": 5}
CARD_DAYS = 60 # how far back the card series / dose log reach
# --------------------------------------------------------------------------- discontinuation risk index
#
# Rule-based index (NOT a trained model) of the risk that the patient drops the therapy, 0–100, for sorting the
# list and for the "talk about adherence" hint. Each factor is a deterministic check over the clinical DB with a
# fixed weight; the score is the capped sum. Weights reflect the known predictors of early antidepressant
# discontinuation (non-adherence, side effects in the first weeks, expressed intent to stop, low mood, alcohol):
#
# factor weight label for the doctor
# missed/skipped doses on >= 3 of the last 7 days 30 "пропуски приёма N/7 дн."
# ... on 1–2 of the last 7 days 15 "пропуски приёма N/7 дн."
# side effects reported >= 3 days in a row 20 "побочные эффекты N дн. подряд"
# ... 1–2 days in a row 10 "побочные эффекты N дн. подряд"
# intent to stop medication voiced in last 14 days 25 "хотел бросить (день N)"
# open, unacknowledged quit_intent alert +15 "не разобран сигнал «хочет бросить»"
# mood <= 3 for >= 3 days in a row 15 "настроение ≤ 3 N дн. подряд"
# alcohol on >= 3 of the last 7 days 15 "алкоголь N дн. из 7"
# ... on 1–2 of the last 7 days 5 "алкоголь N дн. из 7"
#
# Level: >= 60 high, >= 30 medium, otherwise low. "Day" = calendar day in the clinic time zone; streaks end today
# if today's check-in exists, otherwise yesterday (same anchor as app.core.rules), and a day without a check-in
# breaks a streak — we never assume a symptom the patient did not report.
RISK_WINDOW_DAYS = 7
QUIT_WINDOW_DAYS = 14
MOOD_LOW = 3
W_MISSED_MANY, W_MISSED_SOME = 30, 15
W_SIDE_EFFECTS_LONG, W_SIDE_EFFECTS_SHORT = 20, 10
W_QUIT_INTENT, W_QUIT_ALERT_OPEN = 25, 15
W_MOOD_LOW = 15
W_ALCOHOL_MANY, W_ALCOHOL_SOME = 15, 5
STREAK_DAYS = 3
RISK_HIGH, RISK_MEDIUM = 60, 30
@dataclass
class PatientData:
"""Everything the list item and the card are computed from (one patient, loaded once)."""
profile: PatientProfile
checkins: Sequence[DailyCheckin]
results: Sequence[ScaleResult]
doses: Sequence[Dose]
prescriptions: Sequence[Prescription]
alerts: Sequence[Alert] # newest first
async def load_patient_data(session: AsyncSession, profile: PatientProfile) -> PatientData:
token = profile.token
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
rx = (
await session.scalars(select(Prescription).where(Prescription.token == token).order_by(Prescription.id))
).all()
return PatientData(
profile=profile,
checkins=await repo.list_checkins(session, token),
results=await repo.list_scale_results(session, token),
doses=doses,
prescriptions=rx,
alerts=await repo.list_alerts(session, token=token),
)
def _local(ts: datetime, now: datetime) -> date:
return ts.astimezone(now.tzinfo).date()
def _streak_days(checkins: Iterable[DailyCheckin], today: date, pred: Any) -> int:
"""Length of the run of consecutive check-in days satisfying `pred`, ending at the anchor day."""
by_day = {c.day: c for c in checkins if c.day <= today}
day = today if today in by_day else today - timedelta(days=1)
n = 0
while (c := by_day.get(day)) is not None and pred(c):
n += 1
day -= timedelta(days=1)
return n
def _therapy_day(profile: PatientProfile, day: date) -> int:
return (day - profile.therapy_start).days if profile.therapy_start else 0
def _missed_days(d: PatientData, now: datetime, first: date) -> set[date]:
# Same sources as rule missed_doses_7d: dose records and the check-in answer "did not take".
days = {_local(x.planned_at, now) for x in d.doses if x.status in ("missed", "skipped") and x.planned_at <= now}
days |= {c.day for c in d.checkins if c.med_taken is False}
return {x for x in days if first <= x <= now.date()}
def discontinuation_risk(d: PatientData, now: datetime) -> DiscontinuationRisk:
"""Deterministic discontinuation-risk index; see the weight table above."""
today = now.date()
first = today - timedelta(days=RISK_WINDOW_DAYS - 1)
score = 0
factors: list[str] = []
missed = len(_missed_days(d, now, first))
if missed:
score += W_MISSED_MANY if missed >= STREAK_DAYS else W_MISSED_SOME
factors.append(f"пропуски приёма {missed}/{RISK_WINDOW_DAYS} дн.")
side = _streak_days(d.checkins, today, lambda c: bool(c.side_effects))
if side:
score += W_SIDE_EFFECTS_LONG if side >= STREAK_DAYS else W_SIDE_EFFECTS_SHORT
factors.append(f"побочные эффекты {side} дн. подряд")
quit_since = today - timedelta(days=QUIT_WINDOW_DAYS - 1)
quit_days = [c.day for c in d.checkins if c.quit_intent and quit_since <= c.day <= today]
if quit_days:
score += W_QUIT_INTENT
factors.append(f"хотел бросить (день {_therapy_day(d.profile, max(quit_days))})")
if any(a.code == "quit_intent" and a.acked_at is None for a in d.alerts):
score += W_QUIT_ALERT_OPEN
factors.append("не разобран сигнал «хочет бросить»")
low = _streak_days(d.checkins, today, lambda c: c.mood is not None and c.mood <= MOOD_LOW)
if low >= STREAK_DAYS:
score += W_MOOD_LOW
factors.append(f"настроение ≤ {MOOD_LOW} {low} дн. подряд")
drinking = sum(1 for c in d.checkins if first <= c.day <= today and c.alcohol_bucket not in (None, "none"))
if drinking:
score += W_ALCOHOL_MANY if drinking >= STREAK_DAYS else W_ALCOHOL_SOME
factors.append(f"алкоголь {drinking} дн. из {RISK_WINDOW_DAYS}")
score = min(score, 100)
level: Literal["low", "medium", "high"] = (
"high" if score >= RISK_HIGH else "medium" if score >= RISK_MEDIUM else "low"
)
return DiscontinuationRisk(score=score, level=level, factors=factors)
# --------------------------------------------------------------------------- list item
def _complete(d: PatientData, scale: str) -> list[ScaleResult]:
return [r for r in d.results if r.scale == scale and r.complete] # repo returns them oldest first
def trend(d: PatientData) -> Literal["up", "down", "flat", "unknown"]:
"""Direction of symptom scores (up = worse) from the scale with the most recent pair of results."""
pairs = [(s, rs[-2:]) for s in TREND_MIN_CHANGE if len(rs := _complete(d, s)) >= 2]
if not pairs:
return "unknown"
scale, (prev, last) = max(pairs, key=lambda p: p[1][-1].ts)
delta = last.total - prev.total
if abs(delta) < TREND_MIN_CHANGE[scale]:
return "flat"
return "up" if delta > 0 else "down"
def adherence_7d(d: PatientData, now: datetime) -> float | None:
"""Taken / planned doses of the last 7 days; doses still `pending` (not resolved yet) are not counted."""
since = now - timedelta(days=RISK_WINDOW_DAYS)
resolved = [x for x in d.doses if since < x.planned_at <= now and x.status != "pending"]
if not resolved:
return None
return round(sum(1 for x in resolved if x.status == "taken") / len(resolved), 2)
def _drug_label(rx: Prescription) -> str:
return f"{rx.drug} {rx.dose}".strip()
def _active_rx(d: PatientData, today: date) -> Prescription | None:
active = [
r
for r in d.prescriptions
if (r.start_date is None or r.start_date <= today) and (r.end_date is None or r.end_date >= today)
]
return (active or list(d.prescriptions) or [None])[-1]
def _last_event(d: PatientData, now: datetime) -> str:
alert = d.alerts[0] if d.alerts else None
scales = [r for r in d.results if r.complete and r.scale in _SCALE_LABELS]
scale = scales[-1] if scales else None
if scale is not None and (alert is None or scale.ts > alert.ts):
day = _therapy_day(d.profile, _local(scale.ts, now))
return f"{_SCALE_LABELS[scale.scale]} = {scale.total} (день {day})"
return alert.reason if alert is not None else ""
def _age(patient: Patient | None, profile: PatientProfile, today: date) -> int:
birth_year = (patient.birth_year if patient else None) or profile.birth_year
return today.year - birth_year if birth_year else 0
def list_item(d: PatientData, patient: Patient | None, now: datetime) -> PatientListItem:
p = d.profile
today = now.date()
open_alerts = [a for a in d.alerts if a.acked_at is None]
# ADR-006: the colour is derived from open alert levels (the source of truth), not from the cached column.
status = patient_status(AlertLevel[a.level.upper()] for a in open_alerts)
phq9, pcl5 = _complete(d, "phq9"), _complete(d, "pcl5")
rx = _active_rx(d, today)
return PatientListItem(
token=p.token,
display_name=patient.display_name if patient else "Пациент",
age=_age(patient, p, today),
modules=[cast(Module, m) for m in p.modules or [] if m in _MODULES],
drug=_drug_label(rx) if rx else None,
therapy_day=_therapy_day(p, today),
next_visit=p.next_visit,
status_color=status.color,
top_alert_level=cast(LevelName, status.top_level.name.lower()) if status.top_level is not None else None,
open_alerts=len(open_alerts),
phq9_last=phq9[-1].total if phq9 else None,
pcl5_last=pcl5[-1].total if pcl5 else None,
trend=trend(d),
adherence_7d=adherence_7d(d, now),
discontinuation_risk=discontinuation_risk(d, now),
last_event=_last_event(d, now),
)
def sort_key(item: PatientListItem) -> tuple[int, int, str]:
"""red → yellow → grey → green, then discontinuation risk (desc), then name for a stable order."""
return (COLOR_ORDER[item.status_color], -item.discontinuation_risk.score, item.display_name)
# --------------------------------------------------------------------------- card
def alert_out(a: Alert, patient_name: str) -> AlertOut:
level = cast(LevelName, a.level)
return AlertOut(
id=a.id,
token=a.token,
patient_name=patient_name,
ts=a.ts,
level=level,
color=cast(Color, color([AlertLevel[a.level.upper()]])),
code=a.code,
reason=a.reason,
acked_by=a.acked_by,
acked_at=a.acked_at,
)
def series(d: PatientData, now: datetime) -> list[SeriesPoint]:
since = now.date() - timedelta(days=CARD_DAYS)
out: list[SeriesPoint] = []
for c in d.checkins:
if not since <= c.day <= now.date():
continue
if c.mood is not None:
out.append(SeriesPoint(day=c.day, metric="mood", value=float(c.mood)))
if c.sleep_bucket in SLEEP_HOURS:
out.append(SeriesPoint(day=c.day, metric="sleep_hours", value=SLEEP_HOURS[c.sleep_bucket]))
if c.alcohol_bucket in ALCOHOL_LEVEL:
out.append(SeriesPoint(day=c.day, metric="alcohol", value=ALCOHOL_LEVEL[c.alcohol_bucket]))
for r in d.results:
day = _local(r.ts, now)
if r.complete and r.scale in _METRICS and since <= day and r.ts <= now:
out.append(SeriesPoint(day=day, metric=cast(Metric, r.scale), value=float(r.total), scheduled=r.scheduled))
return sorted(out, key=lambda p: (p.day, p.metric))
def doses(d: PatientData, now: datetime) -> list[DoseOut]:
labels = {r.id: _drug_label(r) for r in d.prescriptions}
since = now - timedelta(days=CARD_DAYS)
return [
DoseOut(planned_at=x.planned_at, drug=labels.get(x.prescription_id, ""), status=x.status) # type: ignore[arg-type]
for x in d.doses
if since <= x.planned_at <= now + timedelta(days=1)
]
def prescriptions(d: PatientData) -> list[PrescriptionOut]:
out = []
for r in d.prescriptions:
schedule = dict(r.schedule or {})
out.append(
PrescriptionOut(
drug=r.drug,
dose=r.dose,
schedule={"times": [str(t) for t in schedule.get("times", [])]},
instructions=" ".join(str(t) for t in _as_list(schedule.get("instructions"))),
start_date=r.start_date,
end_date=r.end_date,
)
)
return out
def _as_list(value: Any) -> list[Any]:
if value is None:
return []
return list(value) if isinstance(value, list) else [value]
def _plan_line(step: Any) -> str:
# Same rendering as the dialog's safety-plan reply (app/dialog/actions.py).
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan(session: AsyncSession, token: str) -> list[str] | None:
row = await session.get(SafetyPlan, token)
return [_plan_line(s) for s in row.steps] if row is not None and row.steps else None
async def build_card(session: AsyncSession, d: PatientData, patient: Patient | None, now: datetime) -> PatientCard:
"""Card without the summary (the caller adds it: the LLM call must not hold the DB session)."""
item = list_item(d, patient, now)
return PatientCard(
patient=item,
prescriptions=prescriptions(d),
series=series(d, now),
doses=doses(d, now),
alerts=[alert_out(a, item.display_name) for a in d.alerts],
safety_plan=await safety_plan(session, d.profile.token),
)
+192 -27
View File
@@ -1,11 +1,20 @@
"""API routes `/api/v1` (#34 stubs → #12 implementation). """API routes `/api/v1` (#34 contract → #12 implementation).
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately. Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract). Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`.
Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`,
`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are
disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`.
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
""" """
from fastapi import APIRouter, HTTPException import secrets
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures from app.api import fixtures
from app.api.questionnaire import router as questionnaire_router from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
@@ -23,69 +32,203 @@ from app.api.schemas import (
PatientListItem, PatientListItem,
) )
from app.core import clock from app.core import clock
from app.dialog.contracts import get_engine from app.core.privacy import pseudo_id
from app.integrations.base import MemoryOutbox, integrations from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import Alert, Message, PatientProfile
from app.db.clinical import Prescription as PrescriptionRow
from app.db.identity import Patient
from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations
from app.scheduler import jobs as scheduler from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router) router.include_router(questionnaire_router)
DEFAULT_CLINIC = "clinic-demo"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
CODE_LEN = 8
async def _patients(tokens: list[str]) -> dict[str, Patient]:
"""Identity rows for display name / age only (never put SNILS or phone into a response)."""
if not tokens:
return {}
async with get_identity_session() as s:
rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all()
return {p.token: p for p in rows}
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else "Пациент"
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"]) @router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
async def list_patients(doctor_id: str) -> list[PatientListItem]: async def list_patients(doctor_id: str) -> list[PatientListItem]:
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk.""" """Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3} now = clock.now()
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score)) async with get_clinical_session() as s:
profiles = await repo.patients_of_doctor(s, doctor_id)
data = [await views.load_patient_data(s, p) for p in profiles]
await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now)
patients = await _patients([p.token for p in profiles])
items = [views.list_item(d, patients.get(d.profile.token), now) for d in data]
return sorted(items, key=views.sort_key)
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"]) @router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
async def patient_card(token: str, doctor_id: str) -> PatientCard: async def patient_card(token: str, doctor_id: str) -> PatientCard:
"""Patient card. Every call is written to audit_log as `view_patient` (#12).""" """Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
card = fixtures.card(token) now = clock.now()
if card is None: patient = (await _patients([token])).get(token)
raise HTTPException(404, "patient not found") 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 return card
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"]) @router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]: async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first.""" """Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
alerts = [a for a in fixtures.all_alerts() if a.id > since_id] async with get_clinical_session() as s:
return [a for a in alerts if a.acked_at is None] if open_only else alerts tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id)))
stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id)
if open_only:
stmt = stmt.where(Alert.acked_at.is_(None))
alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
if alerts:
await audit(
s,
actor=doctor_id,
action="view_alerts",
token=None,
details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)},
ts=clock.now(),
)
patients = await _patients(sorted({a.token for a in alerts}))
return [views.alert_out(a, _name(patients, a.token)) for a in alerts]
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"]) @router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut: async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis.""" """Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None) now = clock.now()
if alert is None: async with get_clinical_session() as s:
raise HTTPException(404, "alert not found") alert = await s.get(Alert, alert_id)
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()}) if alert is None:
raise HTTPException(404, "alert not found")
profile = await repo.get_profile(s, alert.token)
if profile is None or profile.doctor_id != body.doctor_id:
raise HTTPException(403, "not your patient")
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
await refresh_status(s, alert.token)
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
out = views.alert_out(alert, "")
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
if alert.level == "acute" and not open_acute:
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
if replies:
await integrations.outbox.push(alert.token, replies)
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"]) @router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut: async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR.""" """Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001") now = clock.now()
try:
token = pseudo_id(body.snils)
except ValueError as exc:
raise HTTPException(422, "SNILS must contain 11 digits") from exc
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
async with get_identity_session() as ids:
if await identity_repo.get_patient(ids, token) is not None:
raise HTTPException(409, "patient already connected")
doctor = await identity_repo.get_doctor(ids, body.doctor_id)
await identity_repo.create_patient(
ids,
token=token,
display_name=body.display_name,
snils=body.snils,
clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC,
doctor_id=body.doctor_id,
now=now,
birth_year=body.birth_year,
sex=body.sex,
)
await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now)
# Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too.
async with get_clinical_session() as s:
await repo.upsert_profile(
s,
token=token,
doctor_id=body.doctor_id,
modules=list(body.modules),
therapy_start=now.date(),
next_visit=body.next_visit,
birth_year=body.birth_year,
sex=body.sex,
)
schedule: dict[str, list[str]] = {"times": list(body.times)}
if body.instructions:
schedule["instructions"] = [body.instructions]
s.add(
PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date())
)
await audit(
s,
actor=body.doctor_id,
action="connect_patient",
token=token,
details={"modules": list(body.modules), "drug": body.drug},
ts=now,
)
return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code))
# ------------------------------------------------------------------ patient dialog (web console / any channel) # ------------------------------------------------------------------ patient dialog (web console / any channel)
async def _require_patient(token: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"]) @router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut: async def dialog_event(token: str, body: DialogIn) -> DialogOut:
await _require_patient(token)
return DialogOut(replies=await get_engine().handle(token, body)) return DialogOut(replies=await get_engine().handle(token, body))
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"]) @router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut: async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console.""" """System-initiated replies (reminders, check-ins) waiting for the web console."""
box = integrations.outbox box = console.memory_outbox()
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else []) return DialogOut(replies=box.drain(token) if box is not None else [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"]) @router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]: async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return [] """Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages."""
async with get_clinical_session() as s:
stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc())
rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all()
return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type]
# ------------------------------------------------------------------ demo controls (ADR-005) # ------------------------------------------------------------------ demo controls (ADR-005)
@@ -108,14 +251,36 @@ async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
@router.post("/demo/reset", tags=["demo"]) @router.post("/demo/reset", tags=["demo"])
async def demo_reset() -> dict[str, str]: async def demo_reset() -> dict[str, str]:
"""Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18)."""
clock.reset() clock.reset()
return {"status": "ok"} seeded = await console.reset_demo()
return {"status": "ok", "seed": "reset" if seeded else "skipped"}
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"]) @router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]: async def demo_personas() -> list[DemoPersona]:
return [ """Patients with a scripted scenario `data/synthetic/<slug>.jsonl`; the demo cabinet's doctor first."""
DemoPersona( scenarios = console.scenario_files()
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl" if not scenarios:
return []
async with get_identity_session() as ids:
patients = (await ids.scalars(select(Patient))).all()
scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios]
if not scripted:
return []
today = clock.today()
async with get_clinical_session() as s:
profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted}
out = []
for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)):
profile = profiles.get(p.token)
start = profile.therapy_start if profile else None
out.append(
DemoPersona(
token=p.token,
display_name=p.display_name,
therapy_day=(today - start).days if start else 0,
scenario=path,
)
) )
] return out
+10
View File
@@ -28,6 +28,16 @@ class Settings(BaseSettings):
gigachat_model: str = "GigaChat" gigachat_model: str = "GigaChat"
gigachat_verify_ssl: bool = False gigachat_verify_ssl: bool = False
# Chat agent (tool-using LLM over RAG + patient record). `auto` = OpenRouter if a key is set, else off
# (extractive RAG / instruction answers stay). OpenRouter is a TEMPORARY prototype provider on synthetic
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = ""
openrouter_model: str = "anthropic/claude-sonnet-4.5"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi. # Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = "" tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None doctor_notify_chat_id: int | None = None
+1
View File
@@ -39,6 +39,7 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
buttons = [ buttons = [
[Button(label=b.checkin, data="menu:checkin")], [Button(label=b.checkin, data="menu:checkin")],
[Button(label=b.med_question, data="menu:med_question")], [Button(label=b.med_question, data="menu:med_question")],
[Button(label=b.kb_question, data="menu:kb_question")],
[Button(label=b.contact_doctor, data="menu:contact_doctor")], [Button(label=b.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")], [Button(label=b.revoke, data="consent:revoke")],
] ]
+107
View File
@@ -0,0 +1,107 @@
"""Questions about the condition and its treatment → knowledge base of clinical guidelines (#64, RAG #60).
menu «Вопрос о лечении» ──▶ kb.question (free text) ──▶ rag.answer: KR quote + source, or «уточните у врача»
free text in idle that looks like a question (veteran_daily `text:idle`, after the check-in signals and the
drug question) ──▶ rag.answer, or the usual support phrase when the KB has no answer
The engine has already scrubbed the text, checked consent and run the safety check (ACUTE never gets here).
Questions about the patient's own drug stay with `med_qa` (instruction only, SPEC §7 п.5); this flow answers
general questions from the guidelines, extractively and without doses (`app.llm.rag.qa`). Logs carry codes only.
"""
import logging
import re
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict
from app.config import get_settings
from app.dialog.contracts import Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger
from app.llm import agent, rag
from app.llm.med_qa import DOSE_QUESTION
log = logging.getLogger(__name__)
QUESTION_STATE = "kb.question"
# `scrub()` placeholders and the punctuation around them: "[NAME], что такое ПТСР?" starts with "что такое".
_PLACEHOLDERS = re.compile(r"^(?:\s*\[[A-Z_]+\][\s,.:;!-]*)+")
class KnowledgeTexts(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
prompt: str
dose_redirect: str
question_starts: list[str]
small_talk: list[str]
@lru_cache
def load_knowledge_texts(path: Path | None = None) -> KnowledgeTexts:
path = path or get_settings().config_dir / "texts" / "knowledge.yaml"
return KnowledgeTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@lru_cache
def _question_start() -> re.Pattern[str]:
starts = sorted(load_knowledge_texts().question_starts, key=len, reverse=True)
return re.compile(r"^\s*(?:" + "|".join(re.escape(s) for s in starts) + r")\b", re.IGNORECASE)
@lru_cache
def _small_talk() -> re.Pattern[str]:
return re.compile(r"\b(?:" + "|".join(re.escape(s) for s in load_knowledge_texts().small_talk) + r")\b", re.I)
def looks_like_question(text: str) -> bool:
"""A "?" anywhere or a question word at the start ("что такое флэшбек", "можно ли ..."); small talk
("как дела?") is not a question for the knowledge base."""
text = _PLACEHOLDERS.sub("", text)
if _small_talk().search(text):
return False
return "?" in text or bool(_question_start().search(text))
async def knowledge_answer(ctx: Ctx, *, explicit: bool) -> str | None:
"""KB answer for `ctx.text`. No answer → «уточните у врача» if the patient asked via the menu (`explicit`),
`None` otherwise (the caller keeps its own reply, e.g. the support phrase)."""
if ctx.text is None:
return None
if DOSE_QUESTION.search(ctx.text.lower()):
log.info("kb: dose question redirected to doctor")
return load_knowledge_texts().dose_redirect
# Tool-using agent first (RAG + ЕМИАС record + drug-safety check); disabled/failed → extractive RAG below.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
log.info("kb: agent answered provider=%s tools=%s", smart.provider, ",".join(smart.tools))
return agent.render(smart)
reply = await rag.answer(ctx.text, audience="patient")
if reply is not None:
log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations))
return reply.text
log.info("kb: no answer explicit=%s", explicit)
return rag.fallback_text("patient") if explicit else None
@trigger("button:menu:kb_question")
async def _menu_question(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=load_knowledge_texts().prompt)], next_state=QUESTION_STATE)
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
# A menu button or a reminder instead of the question: drop the question and let idle handle it.
step = Step(next_state="idle")
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
text = await knowledge_answer(ctx, explicit=True)
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# A question asked yesterday must not swallow today's message.
abandonable("kb")
+7 -1
View File
@@ -42,7 +42,8 @@ from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa from app.llm import agent
from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question" QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text" SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
@@ -435,6 +436,11 @@ async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача».""" """Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None: if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription return load_meds_texts().question.no_prescription
if DOSE_QUESTION.search(ctx.text.lower()) is None:
# Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return agent.render(smart)
answer = await med_qa(rx.drug, ctx.text) answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text() return answer.text if answer is not None else fallback_text()
+7 -2
View File
@@ -47,6 +47,7 @@ from app.db import repo
from app.dialog.contracts import Button, Event, Reply from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows import intake from app.dialog.flows import intake
from app.dialog.flows.core import menu_reply from app.dialog.flows.core import menu_reply
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
from app.dialog.flows.meds import ( from app.dialog.flows.meds import (
answer_question, answer_question,
checkin_of, checkin_of,
@@ -423,11 +424,13 @@ async def _visit(ctx: Ctx, event: Event) -> Step:
@trigger("text:idle", replace=True) @trigger("text:idle", replace=True)
async def _idle_text(ctx: Ctx, event: Event) -> Step: async def _idle_text(ctx: Ctx, event: Event) -> Step:
"""Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol """Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol
→ today's check-in (the rules react); otherwise the neutral support phrase with the menu.""" → today's check-in (the rules react); another question → clinical guidelines (knowledge base); otherwise
the neutral support phrase with the menu."""
assert ctx.text is not None assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text) facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None: if facts is None:
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))]) text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None
return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))])
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
if facts.quit_intent: if facts.quit_intent:
fields["quit_intent"] = True fields["quit_intent"] = True
@@ -445,6 +448,8 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
await save_checkin(ctx, **fields) await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent: if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx)) text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None: if text is None:
text = await ctx.gateway.phrase("support") text = await ctx.gateway.phrase("support")
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects)) return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
+1
View File
@@ -47,6 +47,7 @@ class AgeGateTexts(_Strict):
class IdleButtons(_Strict): class IdleButtons(_Strict):
checkin: str checkin: str
med_question: str med_question: str
kb_question: str
contact_doctor: str contact_doctor: str
revoke: str revoke: str
+51
View File
@@ -0,0 +1,51 @@
"""Demo adapters behind the frozen integration Protocols (SPEC §9, #14).
`setup()` runs when the package is first imported. Every consumer imports `app.integrations.base`, which
initialises this package first, and `app/main.py` is a frozen contract — so the registry is wired before
any request or job without a startup hook. Setup only constructs objects: data files and DB sessions are
opened lazily on the first call, so importing the package never fails on I/O.
Registry after setup:
- `prescriptions` — `MisMock`: clinical DB `prescriptions`, then `data/meds/mis_prescriptions.json`;
- `identity` — `ConnectCodeIdentity`: one-time connect code from the doctor (ЕСИА stand-in);
- `drug_info` — `GrlsExcerpts`: `data/meds/grls/*.yaml`;
- `notifier` — `CompositeNotifier`: in-memory dashboard feed + optional Telegram doctor chat;
- `outbox` — unchanged `MemoryOutbox` (the web console drains it; the Telegram channel wraps it at start).
"""
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.connect_code import ConnectCodeIdentity
from app.integrations.grls import GrlsExcerpts
from app.integrations.mis_mock import MisMock
from app.integrations.notify import CompositeNotifier, NotificationFeed
__all__ = ["CompositeNotifier", "ConnectCodeIdentity", "GrlsExcerpts", "MisMock", "NotificationFeed", "setup"]
_done = False
def setup(*, force: bool = False) -> None:
"""Register the demo adapters. Idempotent; never replaces an adapter someone already swapped in
(a test fake, the Telegram outbox), only the in-memory defaults from `base.py` — unless `force`."""
global _done
if _done and not force:
return
if force or isinstance(integrations.prescriptions, EmptyPrescriptions):
integrations.prescriptions = MisMock()
if force or isinstance(integrations.identity, RejectIdentity):
integrations.identity = ConnectCodeIdentity()
if force or isinstance(integrations.drug_info, NoDrugInfo):
integrations.drug_info = GrlsExcerpts()
# Exact type check: MemoryNotifier subclasses used as test sinks must stay in place.
if force or type(integrations.notifier) is MemoryNotifier:
integrations.notifier = CompositeNotifier()
_done = True
setup()
+58
View File
@@ -0,0 +1,58 @@
"""ЕСИА stand-in: the patient proves identity with the one-time connect code issued by the doctor.
The doctor creates the code in «Подключить пациента» (identity DB `connect_codes`); the patient opens the
QR deep link / enters the code. In prod this is ЕСИА OAuth; the `IdentityProvider` Protocol stays the same.
"""
import logging
from datetime import UTC, timedelta
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.integrations.base import VerifiedPerson
log = logging.getLogger(__name__)
# One connect flow verifies the same code twice: the Telegram channel on `/start <code>` (to bind the chat)
# and then the dialog engine's `new` state (to check the code belongs to this token). A short grace window
# lets the second check pass; after it a used code is rejected like an unknown one.
REDEEM_GRACE = timedelta(minutes=15)
class ConnectCodeIdentity:
"""`IdentityProvider` over identity-DB connect codes."""
def __init__(self, grace: timedelta = REDEEM_GRACE) -> None:
self.grace = grace
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip()
if not code:
return None
# Imported lazily: the integrations package is imported by clinical-side modules, which must not
# pull the identity schema into their import graph (guardrail #5).
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
now = clock.now()
try:
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
log.info("connect code rejected: unknown")
return None
person = VerifiedPerson(token=row.token, doctor_id=row.doctor_id)
if row.used_at is not None:
used_at = row.used_at if row.used_at.tzinfo else row.used_at.replace(tzinfo=UTC)
if now - used_at <= self.grace:
return person
log.info("connect code rejected: already used")
return None
if await identity_repo.redeem_connect_code(s, code=code, now=now) is None:
return None
return person
except (OSError, SQLAlchemyError) as exc:
log.warning("connect code check failed error=%s", type(exc).__name__)
return None
+94
View File
@@ -0,0 +1,94 @@
"""ЕМИАС medical record mock: diagnoses, comorbidities, allergies and other specialists' prescriptions.
Prod: the regional МИС / ЕГИСЗ adapter replaces `EmiasRecordSource` (same `get(token)` contract). Demo: synthetic
records from `data/emias/records.yaml` keyed by synthetic SNILS, converted to tokens with `pseudo_id()` at load
time — so the record handed to the chat agent carries no SNILS, name or phone (guardrail #1). The current
psychiatric prescription comes from the МИС mock (`integrations.prescriptions`).
"""
import logging
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.core.privacy import pseudo_id
from app.integrations.base import integrations
log = logging.getLogger(__name__)
class Diagnosis(BaseModel):
code: str
name: str
class Condition(BaseModel):
name: str
tags: list[str] = Field(default_factory=list)
class OtherMedication(BaseModel):
drug: str
how: str = ""
prescribed_by: str = ""
classes: list[str] = Field(default_factory=list)
class CurrentPrescription(BaseModel):
drug: str
dose: str
instructions: str = ""
class PatientRecord(BaseModel):
"""Clinical-only view of the record (no identifiers): what the chat agent may see."""
diagnoses: list[Diagnosis] = Field(default_factory=list)
conditions: list[Condition] = Field(default_factory=list)
allergies: list[Condition] = Field(default_factory=list)
other_medications: list[OtherMedication] = Field(default_factory=list)
pregnancy: dict[str, str] | None = None
current_prescriptions: list[CurrentPrescription] = Field(default_factory=list)
def default_records_path() -> Path:
return get_settings().data_dir / "emias" / "records.yaml"
@lru_cache
def _load(path: Path) -> dict[str, PatientRecord]:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, PatientRecord] = {}
for snils, rec in (raw.get("records") or {}).items():
out[pseudo_id(snils)] = PatientRecord.model_validate(rec)
return out
class EmiasRecordSource:
def __init__(self, path: Path | None = None) -> None:
self._path = path
async def get(self, token: str) -> PatientRecord | None:
try:
base = _load(self._path or default_records_path()).get(token)
except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__)
base = None
record = base.model_copy(deep=True) if base is not None else PatientRecord()
try:
rx = await integrations.prescriptions.get_prescriptions(token)
except Exception as exc: # noqa: BLE001 — the МИС being down must not break the chat
log.error("prescriptions unavailable: %s", type(exc).__name__)
rx = []
record.current_prescriptions = [
CurrentPrescription(drug=p.drug, dose=p.dose, instructions=p.instructions) for p in rx
]
if base is None and not rx:
return None
return record
emias_records = EmiasRecordSource()
+80
View File
@@ -0,0 +1,80 @@
"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5).
Each `data/meds/grls/<name>.yaml` holds one drug:
inn: сертралин
aliases: [sertraline]
sections:
side_effects: {text: "...", source: "ГРЛС, инструкция по медицинскому применению, раздел «...»"}
Section codes are the ones `app/llm/med_qa.py` understands (side_effects, onset, alcohol, discontinuation,
administration, interactions, overdose). The bot answers drug questions ONLY from these texts, so they are
the single place to verify against the current ГРЛС instruction (see the header of each file).
"""
import logging
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, ValidationError
from app.config import get_settings
from app.integrations.base import DrugSection
log = logging.getLogger(__name__)
class _SectionFile(BaseModel):
text: str
source: str
class _DrugFile(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
sections: dict[str, _SectionFile]
def default_grls_dir() -> Path:
return get_settings().data_dir / "meds" / "grls"
def normalize_inn(name: str) -> str:
"""Lookup key: «Сертралин 50 мг», «сертралин», «Sertraline» -> first word, lower case, ё -> е."""
words = name.strip().lower().replace("ё", "е").split()
return words[0] if words else ""
class GrlsExcerpts:
"""`DrugInfo` over local YAML excerpts. Files are read lazily on the first lookup and cached."""
def __init__(self, directory: Path | None = None) -> None:
self._dir = directory
self._index: dict[str, list[DrugSection]] | None = None
def _load(self) -> dict[str, list[DrugSection]]:
if self._index is not None:
return self._index
directory = self._dir or default_grls_dir()
index: dict[str, list[DrugSection]] = {}
for path in sorted(directory.glob("*.yaml")):
try:
drug = _DrugFile.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
except (OSError, yaml.YAMLError, ValidationError) as exc:
log.warning("grls file skipped file=%s error=%s", path.name, type(exc).__name__)
continue
sections = [
DrugSection(inn=drug.inn, section=code, text=" ".join(s.text.split()), source=s.source)
for code, s in drug.sections.items()
]
for key in (drug.inn, *drug.aliases):
index[normalize_inn(key)] = sections
self._index = index
return index
def known(self) -> list[str]:
"""INNs with excerpts (for the dashboard / tests)."""
return sorted({s[0].inn for s in self._load().values() if s})
async def sections(self, inn: str) -> list[DrugSection]:
return [s.model_copy() for s in self._load().get(normalize_inn(inn), [])]
+97
View File
@@ -0,0 +1,97 @@
"""МИС / ЕГИСЗ stand-in: the patient's prescriptions (SPEC §9).
Source order: the clinical DB table `prescriptions` (written when the doctor connects a patient or by the
demo seed), then the synthetic catalogue `data/meds/mis_prescriptions.json` keyed by token. In prod this is
a regional МИС query; the Protocol stays the same.
"""
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.integrations.base import Prescription
log = logging.getLogger(__name__)
class _CatalogPatient(BaseModel):
persona: str
tokens: list[str] = Field(default_factory=list)
prescriptions: list[Prescription]
class _Catalog(BaseModel):
patients: list[_CatalogPatient]
def _from_row(drug: str, dose: str, schedule: dict[str, Any], start: date | None, end: date | None) -> Prescription:
# The table has no instructions column; the doctor's note («утром после еды») may ride in the schedule JSON.
times = {k: [str(x) for x in v] for k, v in schedule.items() if isinstance(v, list)}
return Prescription(
drug=drug,
dose=dose,
schedule=times or {"times": ["08:00"]},
instructions=str(schedule.get("instructions") or ""),
start_date=start,
end_date=end,
)
def default_catalog_path() -> Path:
return get_settings().data_dir / "meds" / "mis_prescriptions.json"
class MisMock:
"""`PrescriptionSource`: clinical DB first, synthetic JSON catalogue as the fallback."""
def __init__(self, catalog_path: Path | None = None, *, use_db: bool = True) -> None:
self._path = catalog_path
self._use_db = use_db
self._by_token: dict[str, list[Prescription]] | None = None
def _catalog(self) -> dict[str, list[Prescription]]:
# Lazy: the package is imported before startup, a missing/broken file must not break the import.
if self._by_token is None:
path = self._path or default_catalog_path()
index: dict[str, list[Prescription]] = {}
try:
catalog = _Catalog.model_validate(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, ValidationError) as exc:
log.warning("mis_mock catalogue not loaded error=%s", type(exc).__name__)
catalog = _Catalog(patients=[])
for patient in catalog.patients:
for token in patient.tokens:
index[token] = patient.prescriptions
self._by_token = index
return self._by_token
async def _from_db(self, token: str) -> list[Prescription]:
from app.db.clinical import Prescription as PrescriptionRow
from app.db.session import get_clinical_session
async with get_clinical_session() as s:
rows = (
await s.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == token).order_by(PrescriptionRow.id)
)
).all()
return [_from_row(r.drug, r.dose, r.schedule or {}, r.start_date, r.end_date) for r in rows]
async def get_prescriptions(self, token: str) -> list[Prescription]:
if self._use_db:
try:
found = await self._from_db(token)
except (OSError, SQLAlchemyError) as exc:
# DB down or not migrated: the demo still shows the catalogue prescription.
log.warning("mis_mock db lookup failed error=%s", type(exc).__name__)
found = []
if found:
return found
return [p.model_copy(deep=True) for p in self._catalog().get(token, [])]
+141
View File
@@ -0,0 +1,141 @@
"""Doctor notifications (SPEC §9): in-memory feed for the dashboard + optional Telegram doctor chat.
The alert itself is already committed to the clinical DB before `send()` is called (the dashboard reads
alerts from there via the API), so every channel here is best effort: failures are logged, never raised.
Messages carry only the level, the rule reason/code and a short pseudonymous patient id — no PII
(guardrail #8); the reason texts come from `config/rules.yaml`, not from patient messages.
"""
import asyncio
import itertools
import logging
from collections import deque
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from app.config import get_settings
from app.core import clock
from app.integrations.base import AlertLevelName
log = logging.getLogger(__name__)
FEED_SIZE = 500
SHORT_ID_LEN = 6
MAX_REASON_CHARS = 200
TELEGRAM_TIMEOUT_S = 5.0
# Doctor-facing labels (not patient texts); acute alerts are visually distinct in the chat.
LEVEL_LABELS: dict[str, str] = {
"acute": "КРИТИЧНО",
"high": "Высокий",
"moderate": "Умеренный",
"info": "Инфо",
}
ChatSender = Callable[[int, str], Awaitable[Any]]
def short_id(token: str | None) -> str:
return token[:SHORT_ID_LEN] if token else "—"
def format_message(level: str, text: str, token: str | None) -> str:
reason = " ".join(text.split())
if len(reason) > MAX_REASON_CHARS:
reason = reason[: MAX_REASON_CHARS - 1] + "…"
return f"[{LEVEL_LABELS.get(level, level)}] {reason}\nПациент #{short_id(token)}"
class Notification(BaseModel):
id: int
ts: datetime
doctor_id: str
level: str
text: str
token: str | None = None
class NotificationFeed:
"""Bounded in-memory feed; ids increase monotonically so a poller asks only for `recent(since_id)`."""
def __init__(self, size: int = FEED_SIZE) -> None:
self._items: deque[Notification] = deque(maxlen=size)
self._ids = itertools.count(1)
def add(self, doctor_id: str, level: str, text: str, token: str | None, ts: datetime) -> Notification:
item = Notification(id=next(self._ids), ts=ts, doctor_id=doctor_id, level=level, text=text, token=token)
self._items.append(item)
return item
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return [n for n in self._items if n.id > since_id and (doctor_id is None or n.doctor_id == doctor_id)]
class TelegramDoctorChat:
"""Sends to the doctor chat `DOCTOR_NOTIFY_CHAT_ID` through the bot `TG_BOT_TOKEN`.
The aiogram `Bot` is created on the first message only; with either setting empty the channel is off.
"""
def __init__(self, chat_id: int | None = None, bot_token: str | None = None, sender: ChatSender | None = None):
settings = get_settings()
self.chat_id = chat_id if chat_id is not None else settings.doctor_notify_chat_id
self._bot_token = bot_token if bot_token is not None else settings.tg_bot_token
self._sender = sender
self._bot: Any = None
@property
def enabled(self) -> bool:
return self.chat_id is not None and (self._sender is not None or bool(self._bot_token))
def _get_sender(self) -> ChatSender:
if self._sender is None:
from aiogram import Bot
self._bot = Bot(self._bot_token)
self._sender = self._bot.send_message
return self._sender
async def send(self, level: str, text: str, token: str | None) -> None:
if not self.enabled or self.chat_id is None:
return
try:
await asyncio.wait_for(
self._get_sender()(self.chat_id, format_message(level, text, token)), timeout=TELEGRAM_TIMEOUT_S
)
except Exception as exc: # noqa: BLE001 — any channel error; the alert is already in the DB
err = type(exc).__name__
log.warning("doctor chat notify failed level=%s tok=%s error=%s", level, short_id(token), err)
async def aclose(self) -> None:
if self._bot is not None:
await self._bot.session.close()
self._bot = None
self._sender = None
class CompositeNotifier:
"""`Notifier`: dashboard feed + Telegram doctor chat (when configured)."""
def __init__(self, feed: NotificationFeed | None = None, chat: TelegramDoctorChat | None = None) -> None:
self.feed = feed or NotificationFeed()
self._chat = chat
@property
def chat(self) -> TelegramDoctorChat:
# Lazy: settings are read on the first notification, not when the package is imported.
if self._chat is None:
self._chat = TelegramDoctorChat()
return self._chat
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return self.feed.recent(since_id, doctor_id)
async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None:
try:
self.feed.add(doctor_id, level, text, token, clock.now())
except Exception as exc: # noqa: BLE001 — never break the caller
log.warning("notify feed failed error=%s", type(exc).__name__)
await self.chat.send(level, text, token)
+135
View File
@@ -0,0 +1,135 @@
"""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
_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
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},
{"role": "user", "content": str(question)},
]
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:
return _finish(turn.content or "", ctx, provider.name)
messages.append(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
],
}
)
for c in turn.tool_calls:
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout")
return None
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
text = text.strip()
if not text:
return None
if is_forbidden(text):
log.warning("chat agent: forbidden output filtered provider=%s", provider)
return None
if len(text) > MAX_ANSWER_CHARS:
text = text[:MAX_ANSWER_CHARS].rsplit(" ", 1)[0] + "…"
log.info("chat agent ok provider=%s tools=%s sources=%d", provider, ",".join(ctx.used), len(ctx.sources))
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
+59
View File
@@ -0,0 +1,59 @@
"""Try the chat agent from the shell (synthetic personas only):
uv run python -m app.llm.agent "Можно мне ибупрофен вместе с моими таблетками?" --persona andrey
CHAT_AGENT=mock uv run python -m app.llm.agent "Есть ли лактоза в сертралине?"
Prescriptions come from the МИС mock (DB if reachable, else data/meds/mis_prescriptions.json); the question is
scrubbed before it reaches any provider.
"""
import argparse
import asyncio
import logging
from app.config import get_settings
from app.core.privacy import pseudo_id, scrub
from app.integrations.base import Prescription, integrations
from app.llm.agent import answer_chat, build_provider, render
PERSONAS = { # synthetic SNILS from scripts/seed_demo.py → current psychiatric prescription
"andrey": ("900-000-013 34", "сертралин"),
"dmitry": ("900-000-041 01", "сертралин"),
"sergey": ("900-000-037 02", "пароксетин"),
"nikolay": ("900-000-052 03", "сертралин"),
"anna": ("900-000-023 04", "флуоксетин"),
"pavel": ("900-000-045 07", "эсциталопрам"),
}
class _FixedRx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="по назначению врача")]
async def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("question")
p.add_argument("--persona", default="andrey", choices=sorted(PERSONAS))
p.add_argument("-v", "--verbose", action="store_true")
a = p.parse_args()
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING)
snils, drug = PERSONAS[a.persona]
integrations.prescriptions = _FixedRx(drug) # CLI runs without the DB
provider = build_provider()
if provider is None:
raise SystemExit(
f"agent disabled (CHAT_AGENT={get_settings().chat_agent}); set OPENROUTER_API_KEY or CHAT_AGENT=mock"
)
ans = await answer_chat(pseudo_id(snils), scrub(a.question), provider=provider)
if ans is None:
raise SystemExit("no answer (provider error, filtered output or nothing found) — chat falls back to RAG")
print(render(ans))
print(f"\n[provider={ans.provider} tools={', '.join(ans.tools)}]")
if __name__ == "__main__":
asyncio.run(main())
+147
View File
@@ -0,0 +1,147 @@
"""Deterministic drug-safety lookup: composition, contraindications, interactions, patient-specific findings.
The LLM never decides whether a drug is safe for the patient (ADR-007 spirit): it calls `check_patient` and
retells the findings. Matching is by tags shared with the ЕМИАС record (`data/emias/records.yaml`):
condition tags → `cautions[].when_condition`, allergy tags → `composition.excipient_tags`, other medication
classes → `interactions[].with_class`. Source data: `data/meds/drug_safety.yaml` (prototype retelling of ГРЛС).
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.integrations.emias_record import PatientRecord
Severity = Literal["high", "medium", "low"]
class Caution(BaseModel):
when_condition: str
severity: Severity
text: str
class Interaction(BaseModel):
with_class: str
severity: Severity
text: str
class Composition(BaseModel):
form: str
active: str
excipients: list[str] = Field(default_factory=list)
excipient_tags: list[str] = Field(default_factory=list)
note: str = ""
class DrugEntry(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
drug_class: str = ""
classes: list[str] = Field(default_factory=list) # classes this drug belongs to (e.g. nsaid, ssri)
contraindications: list[str] = Field(default_factory=list)
cautions: list[Caution] = Field(default_factory=list)
interactions: list[Interaction] = Field(default_factory=list)
composition: Composition | None = None
pregnancy: str = ""
source: str = ""
class Finding(BaseModel):
severity: Severity
kind: Literal["condition", "allergy", "interaction", "pregnancy", "contraindication"]
text: str
class SafetyDb(BaseModel):
drugs: dict[str, DrugEntry]
allergy_labels: dict[str, str] = Field(default_factory=dict)
def _path() -> Path:
return get_settings().data_dir / "meds" / "drug_safety.yaml"
@lru_cache
def load_db(path: Path | None = None) -> SafetyDb:
raw = yaml.safe_load((path or _path()).read_text(encoding="utf-8"))
classes_of: dict[str, list[str]] = {}
for cls, members in (raw.get("drug_classes") or {}).items():
for m in members:
classes_of.setdefault(m, []).append(cls)
drugs: dict[str, DrugEntry] = {}
for inn, d in (raw.get("drugs") or {}).items():
entry = DrugEntry(
inn=inn,
aliases=d.get("aliases", []),
drug_class=d.get("class", ""),
classes=sorted(set(d.get("classes", [])) | set(classes_of.get(inn, []))),
contraindications=d.get("contraindications", []),
cautions=d.get("cautions", []),
interactions=d.get("interactions", []),
composition=d.get("composition"),
pregnancy=d.get("pregnancy", ""),
source=d.get("source", ""),
)
drugs[inn] = entry
return SafetyDb(drugs=drugs, allergy_labels=raw.get("allergy_labels") or {})
def find_drug(name: str, db: SafetyDb | None = None) -> DrugEntry | None:
"""By INN or alias, case-insensitive; "сертралин 50 мг" → сертралин."""
db = db or load_db()
words = [w.strip(".,!?;:«»()") for w in name.lower().replace("ё", "е").split()]
for entry in db.drugs.values():
names = {entry.inn, *(a.lower() for a in entry.aliases)}
# Russian case endings: "сертралине", "ибупрофена" — match on the stem (name minus the last letter).
if any(w in names or any(len(n) > 4 and w.startswith(n[:-1]) for n in names) for w in words):
return entry
return None
def check_patient(drug: DrugEntry, record: PatientRecord, db: SafetyDb | None = None) -> list[Finding]:
"""Patient-specific findings for `drug`, most severe first. Empty list = nothing found in the reference —
which is NOT a statement that the drug is safe (the answer must say so)."""
db = db or load_db()
out: list[Finding] = []
cond_tags = {t for c in record.conditions for t in c.tags}
for c in drug.cautions:
if c.when_condition in cond_tags:
out.append(Finding(severity=c.severity, kind="condition", text=c.text))
# Excipients vs allergies / intolerances.
allergy_tags = {t for a in record.allergies for t in a.tags}
if drug.composition is not None:
for tag in sorted(allergy_tags & set(drug.composition.excipient_tags)):
label = db.allergy_labels.get(tag, tag)
out.append(
Finding(
severity="high", kind="allergy", text=f"В составе есть {label}, а в карте отмечена непереносимость."
)
)
# Interactions with the patient's other medicines and with the current psychiatric prescription.
other_classes: dict[str, str] = {}
for m in record.other_medications:
for cls in m.classes:
other_classes[cls] = m.drug
for rx in record.current_prescriptions:
entry = find_drug(rx.drug, db)
if entry is not None and entry.inn != drug.inn:
for cls in entry.classes:
other_classes.setdefault(cls, rx.drug)
for i in drug.interactions:
if i.with_class in other_classes:
out.append(
Finding(
severity=i.severity, kind="interaction", text=f"{i.text} (в карте: {other_classes[i.with_class]})."
)
)
if record.pregnancy and drug.pregnancy:
out.append(Finding(severity="medium", kind="pregnancy", text=drug.pregnancy))
order = {"high": 0, "medium": 1, "low": 2}
out.sort(key=lambda f: order[f.severity])
return out
+26
View File
@@ -0,0 +1,26 @@
"""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 (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом
до приёма. Пустой список не значит «безопасно».
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано».
Запрещено:
- ставить или называть диагнозы («у вас депрессия»), даже если они есть в карте;
- назначать, отменять препараты, менять дозы, советовать «примите/не принимайте» — решение всегда за врачом;
- называть дозировки в миллиграммах;
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён."""
+176
View File
@@ -0,0 +1,176 @@
"""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 = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1, "max_tokens": 900}
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)
+179
View File
@@ -0,0 +1,179 @@
"""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"}},
"required": ["drug"],
},
},
},
]
@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 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("ЕМИАС: медицинская карта")
findings = check_patient(entry, rec, load_db())
return {
"found": True,
"inn": entry.inn,
"record": True,
"findings": [f.model_dump() for f in findings],
"note": "Пустой список не означает, что препарат безопасен: решение о приёме принимает врач.",
}
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
+24
View File
@@ -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",
]
+52
View File
@@ -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())
+350
View File
@@ -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)
+229
View File
@@ -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)
+320
View File
@@ -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
)
+112
View File
@@ -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
+1
View File
@@ -7,3 +7,4 @@
- `modules.yaml` — модули и расписания шкал - `modules.yaml` — модули и расписания шкал
- `lexicon/risk_ru.yaml` — лексикон риска - `lexicon/risk_ru.yaml` — лексикон риска
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки) - `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
- `rag/` — база знаний (ADR-011): `sources.yaml` (документы, параметры поиска, синонимы) и `corpus/*.txt`, собранные из PDF в `docs/source/rag/`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+184
View File
@@ -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 мг.
+108
View File
@@ -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", "десенсибилизация и переработка травмы движениями глаз"]
- ["сопа", "синдром отмены антидепрессантов"]
- ["эст", "электросудорожная терапия"]
- ["ртмс", "транскраниальная магнитная стимуляция"]
- ["кошмар", "кошмарные сновидения"]
- ["бессонница", "инсомния", "нарушения сна"]
- ["флешбэк", "флэшбек", "навязчивые воспоминания"]
- ["суицид", "самоубийство", "суицидальные мысли"]
# Разговорная лексика пациента -> термины КР.
- ["таблетки", "препарат", "лекарство"]
- ["бросить", "отменять", "отменить", "отмена", "прекратить", "прекращение приема"]
- ["подействовать", "действовать", "помогать", "эффект", "улучшение"]
- ["антидепрессивный", "тимоаналептический"]
- ["привыкание", "зависимость"]
- ["спорт", "физическая активность", "физические упражнения", "физическая нагрузка"]
- ["побочка", "побочные эффекты", "нежелательные явления"]
- ["тошнит", "тошнота"]
- ["выпить", "спиртное", "алкоголь"]
- ["сколько длится", "продолжительность", "длительность"]
- ["психолог", "психотерапия"]
+1
View File
@@ -63,6 +63,7 @@ idle:
buttons: buttons:
checkin: "Как я сегодня" checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве" med_question: "Вопрос о лекарстве"
kb_question: "Вопрос о лечении"
contact_doctor: "Связаться с врачом" contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие" revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами. # Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
+51
View File
@@ -0,0 +1,51 @@
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
prompt: |-
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
question_starts:
- "что такое"
- "что значит"
- "что означает"
- "почему"
- "зачем"
- "как"
- "когда"
- "сколько"
- "можно ли"
- "нужно ли"
- "надо ли"
- "помогает ли"
- "поможет ли"
- "правда ли"
- "бывает ли"
- "что делать"
- "что будет"
- "чем"
- "где"
- "есть ли"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
small_talk:
- "как дела"
- "как ты"
- "как вы"
- "как жизнь"
- "как поживаете"
- "что нового"
- "кто ты"
- "ты кто"
- "ты бот"
- "ты робот"
- "привет"
- "спасибо"
+2
View File
@@ -21,6 +21,8 @@ phrases:
doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами." doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами."
# med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя. # med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя.
med_answer: "{excerpt}" med_answer: "{excerpt}"
# RAG (#60): базовый смысл = выдержка из клинических рекомендаций; LLM только упрощает её, ничего не добавляя.
rag_answer: "{excerpt}"
# Шаблон саммари для врача (только по структурированным данным). # Шаблон саммари для врача (только по структурированным данным).
summary: summary:
+24
View File
@@ -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: ""
+132
View File
@@ -0,0 +1,132 @@
"""Thin HTTP client for the dashboard. The frozen API schemas (`app/api/schemas.py`) are the only contract.
The dashboard never touches the databases: everything goes over HTTP to `API_BASE_URL`. Short timeouts keep the
UI responsive when the backend is down; every failure is turned into `ApiError` with a Russian message that the
page shows as a banner instead of a traceback.
"""
from __future__ import annotations
import logging
import os
from typing import Any, TypeVar
import httpx
from pydantic import BaseModel, TypeAdapter, ValidationError
from app.api.schemas import (
AckIn,
AlertOut,
ClockOut,
ConnectPatientIn,
ConnectPatientOut,
PatientCard,
PatientListItem,
)
log = logging.getLogger(__name__)
DEFAULT_BASE_URL = "http://localhost:8000"
DEFAULT_DOCTOR_ID = "doc-smirnova"
API_PREFIX = "/api/v1"
T = TypeVar("T")
class ApiError(Exception):
"""Any API failure, with a message safe to show to the doctor (no PII, no stack traces)."""
def __init__(self, message: str, status: int | None = None) -> None:
super().__init__(message)
self.message = message
self.status = status
class ApiUnavailable(ApiError):
"""Backend not reachable (connection refused, DNS, timeout)."""
class ApiClient:
def __init__(
self,
base_url: str | None = None,
doctor_id: str | None = None,
*,
timeout: float = 3.0,
transport: httpx.BaseTransport | None = None,
) -> None:
base = (base_url or os.environ.get("API_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
if not base.endswith(API_PREFIX):
base += API_PREFIX
self.base_url = base
self.doctor_id = doctor_id or os.environ.get("DOCTOR_ID") or DEFAULT_DOCTOR_ID
self._http = httpx.Client(
base_url=base, timeout=httpx.Timeout(timeout, connect=min(timeout, 1.5)), transport=transport
)
# ------------------------------------------------------------------ doctor endpoints
def patients(self) -> list[PatientListItem]:
return self._get(f"/doctors/{self.doctor_id}/patients", list[PatientListItem])
def patient_card(self, token: str) -> PatientCard:
return self._get(f"/patients/{token}", PatientCard, params={"doctor_id": self.doctor_id})
def alerts(self, since_id: int = -1, *, open_only: bool = False) -> list[AlertOut]:
params = {"since_id": since_id, "open_only": str(open_only).lower()}
return self._get(f"/doctors/{self.doctor_id}/alerts", list[AlertOut], params=params)
def ack(self, alert_id: int) -> AlertOut:
return self._request("POST", f"/alerts/{alert_id}/ack", AlertOut, json=AckIn(doctor_id=self.doctor_id))
def connect(self, body: ConnectPatientIn) -> ConnectPatientOut:
return self._request("POST", "/patients/connect", ConnectPatientOut, json=body)
def clock(self) -> ClockOut | None:
"""Demo clock (ADR-005); optional decoration, so failures return None instead of raising."""
try:
return self._get("/demo/clock", ClockOut)
except ApiError:
return None
# ------------------------------------------------------------------ plumbing
def _get(self, path: str, model: type[T], params: dict[str, Any] | None = None) -> T:
return self._request("GET", path, model, params=params)
def _request(
self,
method: str,
path: str,
model: type[T],
*,
params: dict[str, Any] | None = None,
json: BaseModel | None = None,
) -> T:
payload = json.model_dump(mode="json") if json is not None else None
try:
resp = self._http.request(method, path, params=params, json=payload)
except (httpx.ConnectError, httpx.TimeoutException) as exc:
log.warning("api unavailable: %s %s (%s)", method, _route(path), type(exc).__name__)
raise ApiUnavailable("API недоступен — проверьте, что сервис запущен") from exc
except httpx.HTTPError as exc:
log.warning("api transport error: %s %s (%s)", method, _route(path), type(exc).__name__)
raise ApiUnavailable("API недоступен — ошибка соединения") from exc
if resp.status_code == 404:
raise ApiError("Не найдено", 404)
if resp.status_code >= 400:
log.warning("api error: %s %s -> %s", method, _route(path), resp.status_code)
raise ApiError(f"Ошибка API ({resp.status_code})", resp.status_code)
try:
return TypeAdapter(model).validate_python(resp.json())
except (ValueError, ValidationError) as exc:
log.warning("api bad payload: %s %s", method, _route(path))
raise ApiError("API вернул неожиданный ответ") from exc
def close(self) -> None:
self._http.close()
def _route(path: str) -> str:
"""Path without identifiers, so logs carry no patient tokens."""
parts = path.strip("/").split("/")
return "/" + "/".join(p if not any(ch.isdigit() for ch in p) and not p.startswith("tok") else "*" for p in parts)
+118 -13
View File
@@ -1,18 +1,123 @@
"""Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.""" """Doctor dashboard and demo console (Streamlit). Talks to the backend only via the API.
import os Entry point: `streamlit run dashboard/app.py`. Pages are discovered from `dashboard/pages/*.py` — adding a page
is adding a file, app.py never needs editing:
import httpx * file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
import streamlit as st spaces (`2_Демо-консоль.py` → «Демо-консоль»);
* section: pages with N == 1 go to «Кабинет врача», the rest to «Демо»;
* optional module-level constants (read as text, the file is not imported):
`NAV_TITLE = "..."`, `NAV_ICON = ":material/terminal:"`, `NAV_SECTION = "..."`, `NAV_URL = "slug"`.
"""
API = os.environ.get("API_BASE_URL", "http://localhost:8000") from __future__ import annotations
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide") import re
st.title("Кабинет врача") import sys
st.caption("Прототип на синтетических данных") from dataclasses import dataclass
from pathlib import Path
try: ROOT = Path(__file__).resolve().parents[1]
status = httpx.get(f"{API}/health", timeout=2).json().get("status") _HERE = str(Path(__file__).resolve().parent)
except httpx.HTTPError: # `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
status = "недоступен" # (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
st.write(f"API: **{status}**") sys.path[:] = [p for p in sys.path if p not in ("", _HERE) and Path(p or ".").resolve() != Path(_HERE)]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
PAGES_DIR = Path(__file__).resolve().parent / "pages"
DOCTOR_SECTION = "Кабинет врача"
DEMO_SECTION = "Демо"
DEFAULT_ICONS = {
"Пациенты": ":material/groups:",
"Подключить пациента": ":material/person_add:",
"Демо-консоль": ":material/smartphone:",
"Сплит-экран": ":material/splitscreen:",
}
_NAME_RE = re.compile(r"^(\d+)([a-z]?)_(.+)$")
_META_RE = re.compile(r"""^NAV_(TITLE|ICON|SECTION|URL)\s*(?::\s*str\s*)?=\s*["'](.+?)["']\s*$""", re.MULTILINE)
_TRANSLIT = dict(
zip(
"абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
[*"abvgdee", "zh", *"zijklmnoprstufh", "ts", "ch", "sh", "sch", "_", "y", "_", "e", "yu", "ya"],
strict=True,
)
)
@dataclass(frozen=True)
class PageSpec:
path: Path
title: str
icon: str
section: str
url_path: str
order: tuple[int, str]
def slugify(title: str) -> str:
out = "".join(_TRANSLIT.get(ch, ch) for ch in title.lower())
out = out.replace("_", "")
return re.sub(r"[^a-z0-9]+", "-", out).strip("-") or "page"
def discover_pages(pages_dir: Path = PAGES_DIR) -> list[PageSpec]:
specs = []
for path in pages_dir.glob("*.py"):
m = _NAME_RE.match(path.stem)
if not m:
continue
number, letter, raw = int(m.group(1)), m.group(2), m.group(3)
meta = dict(_META_RE.findall(path.read_text(encoding="utf-8")))
title = meta.get("TITLE", raw.replace("_", " "))
specs.append(
PageSpec(
path=path,
title=title,
icon=meta.get("ICON", DEFAULT_ICONS.get(title, ":material/description:")),
section=meta.get("SECTION", DOCTOR_SECTION if number == 1 else DEMO_SECTION),
url_path=meta.get("URL", slugify(title)),
order=(number, letter),
)
)
return sorted(specs, key=lambda s: s.order)
def main() -> None:
import streamlit as st
from dashboard import theme
from dashboard.components import live_alerts, state, ui
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
st.html(theme.CSS)
st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large")
specs = discover_pages()
nav: dict[str, list[st.Page]] = {}
section_of: dict[str, str] = {}
for i, s in enumerate(specs):
page = st.Page(str(s.path), title=s.title, icon=s.icon, url_path=s.url_path, default=i == 0)
nav.setdefault(s.section, []).append(page)
section_of[s.url_path] = s.section
current = st.navigation(nav, position="sidebar", expanded=True)
with st.sidebar:
st.html(
'<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
"Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
)
st.caption("Прототип на синтетических данных")
short, _ = state.doctor_names()
st.html(ui.workspace_header(short, current.title))
live_alerts.flush_pending_toasts()
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario.
live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
current.run()
ui.footer()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="228" height="48" viewBox="0 0 228 48" role="img" aria-label="PTSRaiAgent — помощник врача">
<rect x="0" y="6" width="36" height="36" rx="11" fill="#285e4e"/>
<path d="M18 15v18M9 24h18" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<g font-family="-apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif">
<text x="47" y="25" fill="#213b38" font-size="21" font-weight="600" letter-spacing="-.7">PTSRaiAgent</text>
<text x="48" y="39" fill="#63736f" font-size="9" letter-spacing="1">ПОМОЩНИК ВРАЧА</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 626 B

+173
View File
@@ -0,0 +1,173 @@
"""Plotly figures of the patient card. Pure builders: data in, `go.Figure` out (tested without Streamlit).
Chart rules: one y-axis per chart (scales of different range are separate charts, daily metrics are small
multiples on a shared time axis), a single neutral series colour, status colours only for thresholds.
"""
from __future__ import annotations
from collections.abc import Iterable
from datetime import date, timedelta
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from app.api.schemas import SeriesPoint
from dashboard import theme
from dashboard.components.helpers import SCALE_MAX, metric_points, severity_bands, short_date
# Band shading: one warm hue with growing opacity (sequential), so "higher = worse" reads without a legend.
_BAND_ALPHA = [0.0, 0.05, 0.09, 0.14, 0.20]
def _day_ticks(days: list[date], max_ticks: int = 10) -> dict[str, object]:
if not days:
return {}
first, last = min(days), max(days)
span = (last - first).days + 1
step = max(1, -(-span // max_ticks))
ticks = [first + timedelta(days=i) for i in range(0, span, step)]
return {"tickmode": "array", "tickvals": ticks, "ticktext": [short_date(d) for d in ticks]}
def _point_label(p: SeriesPoint) -> str:
return f"{p.value:g}" if p.scheduled else f"{p.value:g} · внепл."
def _therapy_day(d: date, start: date | None) -> str:
return f"день {(d - start).days}" if start else short_date(d)
def scale_figure(
series: Iterable[SeriesPoint], scale_id: str, title: str, start: date | None = None
) -> go.Figure | None:
"""Total score over time with the severity gradations from config/scales; unscheduled sessions are hollow."""
pts = metric_points(series, scale_id)
if not pts:
return None
top = SCALE_MAX.get(scale_id, max(p.value for p in pts))
fig = go.Figure()
bands = severity_bands(scale_id)
for i, b in enumerate(bands):
alpha = _BAND_ALPHA[min(i, len(_BAND_ALPHA) - 1)] if len(bands) > 3 else [0.0, 0.08, 0.18][min(i, 2)]
fig.add_hrect(y0=b.lo, y1=b.hi + 1 if b.hi < top else top, line_width=0, fillcolor=f"rgba(214,69,69,{alpha})")
fig.add_annotation(
x=0,
xref="paper",
xanchor="left",
y=(b.lo + min(b.hi + 1, top)) / 2,
text=b.label,
showarrow=False,
font={"size": 10, "color": "rgba(122,130,140,0.85)"},
)
xs = [p.day for p in pts]
hover = [f"{short_date(p.day)} · {_therapy_day(p.day, start)}" for p in pts]
fig.add_trace(
go.Scatter(
x=xs,
y=[p.value for p in pts],
mode="lines+markers+text",
line={"color": theme.SERIES, "width": 2},
marker={
"size": [9 if p.scheduled else 13 for p in pts],
"symbol": ["circle" if p.scheduled else "diamond-open" for p in pts],
"color": theme.SERIES,
"line": {"width": [2 if p.scheduled else 2.5 for p in pts], "color": "#FFFFFF"},
},
text=[_point_label(p) if i == len(pts) - 1 else "" for i, p in enumerate(pts)],
textposition="top left",
textfont={"color": theme.INK, "size": 12},
customdata=[[h, "плановый" if p.scheduled else "внеплановый"] for h, p in zip(hover, pts, strict=True)],
hovertemplate="%{customdata[0]}<br><b>%{y:g}</b> баллов · %{customdata[1]}<extra></extra>",
name=title,
)
)
pad = timedelta(days=1)
fig.update_layout(
theme.plotly_layout(title={"text": title, "font": {"size": 14, "color": theme.INK}, "x": 0}, height=260),
hovermode="closest",
)
fig.update_xaxes(**theme.AXIS, **_day_ticks(xs, 4), tickangle=0, range=[min(xs) - pad, max(xs) + pad])
fig.update_yaxes(**theme.AXIS, range=[0, top], fixedrange=True)
return fig
def wellbeing_figure(series: Iterable[SeriesPoint], start: date | None = None) -> go.Figure | None:
"""Daily check-in as three small multiples on one time axis: mood 0–10, sleep hours, alcohol 0/1/2."""
data = list(series)
mood, sleep, alcohol = (metric_points(data, m) for m in ("mood", "sleep_hours", "alcohol"))
if not (mood or sleep or alcohol):
return None
days = sorted({p.day for p in mood + sleep + alcohol})
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
vertical_spacing=0.09,
row_heights=[0.42, 0.33, 0.25],
subplot_titles=("Настроение, 0–10", "Сон, часов", "Алкоголь"),
)
red_line = {"color": theme.TONES["red"].solid, "width": 1, "dash": "dot"}
def hov(p: SeriesPoint) -> str:
return f"{short_date(p.day)} · {_therapy_day(p.day, start)}"
if mood:
# Mood ≤ 3 for three days triggers an unscheduled PHQ-9 (config/rules.yaml) — shade that zone.
fig.add_hrect(y0=0, y1=3, line_width=0, fillcolor="rgba(214,69,69,0.08)", row=1, col=1)
fig.add_trace(
go.Scatter(
x=[p.day for p in mood],
y=[p.value for p in mood],
mode="lines+markers",
line={"color": theme.SERIES, "width": 2},
marker={"size": 8, "color": theme.SERIES, "line": {"width": 2, "color": "#FFFFFF"}},
customdata=[hov(p) for p in mood],
hovertemplate="%{customdata}<br>настроение <b>%{y:g}</b>/10<extra></extra>",
),
row=1,
col=1,
)
if sleep:
fig.add_trace(
go.Bar(
x=[p.day for p in sleep],
y=[p.value for p in sleep],
marker={"color": [theme.TONES["red"].solid if p.value < 4 else "#9DBFEA" for p in sleep]},
marker_cornerradius=4,
customdata=[hov(p) for p in sleep],
hovertemplate="%{customdata}<br>сон ≈ <b>%{y:g}</b> ч<extra></extra>",
),
row=2,
col=1,
)
fig.add_hline(y=4, line=red_line, row=2, col=1)
if alcohol:
alc_label = {0: "нет", 1: "1–2 порции", 2: "больше"}
fig.add_trace(
go.Bar(
x=[p.day for p in alcohol],
y=[max(p.value, 0.08) for p in alcohol],
marker={
"color": [
{0: theme.LINE, 1: theme.TONES["yellow"].solid}.get(int(p.value), theme.TONES["red"].solid)
for p in alcohol
]
},
marker_cornerradius=4,
customdata=[[hov(p), alc_label.get(int(p.value), "?")] for p in alcohol],
hovertemplate="%{customdata[0]}<br>алкоголь: <b>%{customdata[1]}</b><extra></extra>",
),
row=3,
col=1,
)
fig.update_layout(theme.plotly_layout(height=430, bargap=0.25, hovermode="x"))
fig.update_annotations(font={"size": 12, "color": theme.INK_2}, x=0, xanchor="left")
pad = timedelta(hours=14)
fig.update_xaxes(**theme.AXIS, range=[days[0] - pad, days[-1] + pad])
fig.update_xaxes(**_day_ticks(days), row=3, col=1)
fig.update_yaxes(**theme.AXIS, fixedrange=True)
fig.update_yaxes(range=[0, 10.5], tickvals=[0, 3, 5, 10], row=1, col=1)
fig.update_yaxes(range=[0, 9], tickvals=[0, 4, 8], row=2, col=1)
fig.update_yaxes(range=[0, 2.2], tickvals=[0, 1, 2], ticktext=["0", "1–2", "3+"], row=3, col=1)
return fig
+299
View File
@@ -0,0 +1,299 @@
"""Pure presentation logic of the dashboard (no Streamlit calls), unit-tested in tests/test_dashboard_helpers.py.
Clinical meaning stays on the backend: colours come from ADR-006 levels computed by the rules engine and the
discontinuation-risk index is computed server-side. Here we only order, label and reshape that data for display.
"""
from __future__ import annotations
import random
import re
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from datetime import date, datetime
from functools import cache
from pathlib import Path
from zoneinfo import ZoneInfo
import yaml
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, PatientListItem, SeriesPoint
TZ = ZoneInfo("Europe/Moscow")
REPO_ROOT = Path(__file__).resolve().parents[2]
# ADR-006 ordering for the doctor's attention: acute first, then high/moderate, then info-only, then quiet.
STATUS_ORDER = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
MODULE_LABELS = {"veteran": "ветеран", "depression": "депрессия", "family": "семья"}
LEVEL_LABELS = {"acute": "Кризис", "high": "Высокий", "moderate": "Умеренный", "info": "Инфо"}
MONTHS_SHORT = ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]
DRUGS: dict[str, list[str]] = {
"сертралин": ["25 мг", "50 мг", "100 мг", "150 мг"],
"пароксетин": ["10 мг", "20 мг", "30 мг"],
"эсциталопрам": ["5 мг", "10 мг", "20 мг"],
"флуоксетин": ["20 мг", "40 мг"],
}
# ------------------------------------------------------------------ list: ordering, filtering, KPIs
def sort_patients(items: Iterable[PatientListItem]) -> list[PatientListItem]:
"""red → yellow → grey → green; inside a colour the higher discontinuation risk goes first."""
return sorted(items, key=lambda p: (STATUS_ORDER[p.status_color], -p.discontinuation_risk.score, p.display_name))
def filter_patients(
items: Iterable[PatientListItem], color: str | None = None, query: str = ""
) -> list[PatientListItem]:
q = query.strip().lower()
return [
p
for p in items
if (color is None or p.status_color == color)
and (not q or q in p.display_name.lower() or q in (p.drug or "").lower())
]
@dataclass(frozen=True)
class Kpis:
total: int
red: int
yellow: int
grey: int
green: int
high_risk: int
def kpis(items: Sequence[PatientListItem]) -> Kpis:
by = {c: sum(1 for p in items if p.status_color == c) for c in STATUS_ORDER}
high = sum(1 for p in items if p.discontinuation_risk.level == "high")
return Kpis(len(items), by["red"], by["yellow"], by["grey"], by["green"], high)
# ------------------------------------------------------------------ small visual mappings
@dataclass(frozen=True)
class View:
text: str
tone: str # key of theme.TONES
hint: str = ""
def trend_view(trend: str) -> View:
"""Direction of symptom scores; "up" means symptoms got worse (schemas.PatientListItem.trend)."""
return {
"up": View("↑", "red", "симптомы нарастают"),
"down": View("↓", "green", "симптомы снижаются"),
"flat": View("→", "grey", "без динамики"),
}.get(trend, View("·", "grey", "мало данных"))
def risk_view(risk: DiscontinuationRisk) -> View:
"""Badge for the rule-based discontinuation-risk index (score 0–100) with factors as a tooltip."""
label, tone = {"high": ("Высокий", "red"), "medium": ("Средний", "yellow"), "low": ("Низкий", "green")}[risk.level]
factors = "\n".join(f"• {f}" for f in risk.factors) or "• значимых факторов нет"
return View(f"{label} · {risk.score}", tone, f"Риск прерывания терапии: {risk.score}/100\n{factors}")
def adherence_view(adherence: float | None) -> View:
"""7-day adherence. ≥ 80 % is the usual "adherent" cut-off for antidepressants; < 60 % needs a call."""
if adherence is None:
return View("—", "grey")
pct = round(adherence * 100)
tone = "green" if adherence >= 0.8 else "yellow" if adherence >= 0.6 else "red"
return View(f"{pct}%", tone)
def status_view(color: str, top_level: str | None = None) -> View:
if color == "red":
return View("Срочно", "red", "Кризисное уведомление")
if color == "yellow":
return View("Внимание", "yellow", "врачу ≤ 24 ч или к визиту")
if color == "grey":
return View("Нет ответа", "grey", "молчание / только инфо-флаги")
return View("Без уведомлений", "green", "открытых сигналов нет")
def level_tone(level: str) -> str:
return {"acute": "red", "high": "yellow", "moderate": "yellow"}.get(level, "grey")
# ------------------------------------------------------------------ scales
@dataclass(frozen=True)
class Band:
lo: float
hi: float
code: str
label: str
SCALE_MAX = {"phq9": 27, "pcl5": 80}
@cache
def severity_bands(scale_id: str) -> tuple[Band, ...]:
"""Severity gradations from config/scales/<id>.yaml (ADR-008: thresholds live in config, not code)."""
path = REPO_ROOT / "config" / "scales" / f"{scale_id}.yaml"
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except OSError:
return ()
rows = data.get("severity", [])
return tuple(Band(float(b["min"]), float(b["max"]), str(b["code"]), str(b["label"])) for b in rows)
def severity_label(scale_id: str, value: float | None) -> str:
if value is None:
return ""
for b in severity_bands(scale_id):
if b.lo <= value <= b.hi:
return b.label
return ""
def metric_points(series: Iterable[SeriesPoint], metric: str) -> list[SeriesPoint]:
return sorted((p for p in series if p.metric == metric), key=lambda p: p.day)
def last_and_prev(series: Iterable[SeriesPoint], metric: str) -> tuple[float | None, float | None]:
pts = metric_points(series, metric)
last = pts[-1].value if pts else None
prev = pts[-2].value if len(pts) > 1 else None
return last, prev
# ------------------------------------------------------------------ dose calendar
@dataclass
class DoseGrid:
"""Heat-strip of doses: one column per calendar day, one row per scheduled time of day."""
days: list[date] = field(default_factory=list)
slots: list[str] = field(default_factory=list)
cells: dict[tuple[str, date], str] = field(default_factory=dict)
counts: dict[str, int] = field(default_factory=dict)
@property
def adherence(self) -> float | None:
"""Share of resolved doses taken (pending doses are not counted yet)."""
resolved = sum(self.counts.get(s, 0) for s in ("taken", "missed", "skipped"))
return self.counts.get("taken", 0) / resolved if resolved else None
def _local(dt: datetime) -> datetime:
return dt.astimezone(TZ) if dt.tzinfo else dt
def dose_grid(doses: Iterable[DoseOut]) -> DoseGrid:
grid = DoseGrid(counts={"taken": 0, "missed": 0, "skipped": 0, "pending": 0})
items = sorted(doses, key=lambda d: d.planned_at)
if not items:
return grid
days: set[date] = set()
slots: set[str] = set()
for d in items:
local = _local(d.planned_at)
day, slot = local.date(), local.strftime("%H:%M")
days.add(day)
slots.add(slot)
grid.cells[(slot, day)] = d.status
grid.counts[d.status] += 1
first, last = min(days), max(days)
grid.days = [date.fromordinal(o) for o in range(first.toordinal(), last.toordinal() + 1)]
grid.slots = sorted(slots)
return grid
# ------------------------------------------------------------------ alerts
def apply_local_acks(alerts: Iterable[AlertOut], acks: dict[int, tuple[str, datetime]]) -> list[AlertOut]:
"""Overlay acks done in this session (so the UI reflects the click even before the next poll)."""
out = []
for a in alerts:
if a.acked_at is None and a.id in acks:
by, at = acks[a.id]
a = a.model_copy(update={"acked_by": by, "acked_at": at})
out.append(a)
return out
def split_new(alerts: Iterable[AlertOut], seen: set[int]) -> list[AlertOut]:
return sorted((a for a in alerts if a.id not in seen), key=lambda a: a.id)
def sort_alerts(alerts: Iterable[AlertOut]) -> list[AlertOut]:
"""Open first (acute before others), then newest first."""
rank = {"acute": 0, "high": 1, "moderate": 2, "info": 3}
return sorted(alerts, key=lambda a: (a.acked_at is not None, rank.get(a.level, 9), -a.ts.timestamp()))
# ------------------------------------------------------------------ Russian formatting
def plural_ru(n: int, one: str, few: str, many: str) -> str:
n = abs(n)
if n % 10 == 1 and n % 100 != 11:
return one
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
return few
return many
def age_label(age: int) -> str:
if age <= 0:
return "возраст не указан"
return f"{age} {plural_ru(age, 'год', 'года', 'лет')}"
def short_date(d: date) -> str:
return f"{d.day} {MONTHS_SHORT[d.month - 1]}"
def when_label(dt: datetime, today: date | None = None) -> str:
local = _local(dt)
day = "сегодня" if today and local.date() == today else short_date(local.date())
return f"{day}, {local:%H:%M}"
def visit_label(visit: date | None, today: date | None) -> str:
if visit is None:
return "визит не назначен"
if today is None:
return f"визит {short_date(visit)}"
delta = (visit - today).days
if delta == 0:
return "визит сегодня"
if delta < 0:
return f"визит {short_date(visit)} (просрочен)"
return f"визит {short_date(visit)} · через {delta} {plural_ru(delta, 'день', 'дня', 'дней')}"
# ------------------------------------------------------------------ connect form
def absolute_link(link: str, public_url: str) -> str:
"""The API may return a relative web-console link (`/console?code=…`); a QR needs an absolute URL."""
if link.startswith(("http://", "https://", "tg://")):
return link
return public_url.rstrip("/") + "/" + link.lstrip("/")
def snils_checksum(digits: str) -> int:
"""Control number of a SNILS (Pension Fund algorithm): Σ digit·(9−i), then mod 101, 100/101 → 0."""
s = sum(int(ch) * (9 - i) for i, ch in enumerate(digits))
if s < 100:
return s
if s in (100, 101):
return 0
s %= 101
return 0 if s == 100 else s
def format_snils(digits: str, check: int) -> str:
return f"{digits[0:3]}-{digits[3:6]}-{digits[6:9]} {check:02d}"
def is_valid_snils(value: str) -> bool:
d = re.sub(r"\D", "", value)
return len(d) == 11 and snils_checksum(d[:9]) == int(d[9:])
def synthetic_snils(rng: random.Random | None = None) -> str:
"""Random checksum-valid SNILS for synthetic test patients. Starts with 000 so it never looks like a real one."""
r = rng or random.Random()
digits = "000" + "".join(str(r.randint(0, 9)) for _ in range(6))
return format_snils(digits, snils_checksum(digits))
+85
View File
@@ -0,0 +1,85 @@
"""Live alerts across all pages: polls the API every few seconds, toasts new alerts, pins open acute ones on top.
Uses the built-in `st.fragment(run_every=...)`: only this fragment re-runs on the timer, the page is untouched
unless a new alert arrives on a doctor page (then the whole app reruns so the list and the card pick it up).
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import AlertOut
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import helpers as h
from dashboard.components import state, ui
POLL_SECONDS = 2.5
MAX_BANNER = 3
def _toast(a: AlertOut) -> None:
icon = theme.TONES[h.level_tone(a.level)].icon
st.toast(f"**{a.patient_name}** — {a.reason}", icon=icon, duration="long" if a.level == "acute" else "short")
def flush_pending_toasts() -> None:
"""Toasts queued right before a full-app rerun are shown on the next run (a rerun drops pending deltas)."""
for a in st.session_state.pop("pending_toasts", []):
_toast(a)
def _banner(acute: list[AlertOut]) -> None:
for a in acute[:MAX_BANNER]:
left, right = st.columns([8, 1.6], vertical_alignment="center")
with left:
st.html(
ui.banner(
f"<b>Кризисный сигнал · {escape(a.patient_name)}</b> — {escape(a.reason)}"
f'<div style="font-size:.8rem;margin-top:2px">{escape(h.when_label(a.ts))} · '
"по протоколу — связаться с пациентом сегодня</div>",
tone="red",
pulse=True,
)
)
with right:
if st.button(
"Карточка", key=f"acute-open-{a.id}", type="primary", icon=":material/arrow_forward:", width="stretch"
):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": a.token})
if len(acute) > MAX_BANNER:
st.caption(f"Ещё кризисных сигналов: {len(acute) - MAX_BANNER}")
def render(*, rerun_app_on_new: bool = True) -> None:
"""Place once per run (app.py does it above every page)."""
@st.fragment(run_every=POLL_SECONDS)
def _poll() -> None:
ss = st.session_state
try:
alerts = state.client().alerts(open_only=True)
except ApiError:
return # pages show their own "API недоступен" banner; the poller just retries next tick
acked = state.local_acks()
alerts = [a for a in alerts if a.acked_at is None and a.id not in acked]
seen: set[int] | None = ss.get("seen_alert_ids")
if seen is None:
# First poll of the session: what is already open is not "new" — no toast storm on page load.
ss["seen_alert_ids"] = {a.id for a in alerts}
new: list[AlertOut] = []
else:
new = h.split_new(alerts, seen)
seen.update(a.id for a in new)
if new and rerun_app_on_new:
ss["pending_toasts"] = ss.get("pending_toasts", []) + new
st.rerun(scope="app")
for a in new:
_toast(a)
_banner(h.sort_alerts(a for a in alerts if a.level == "acute"))
_poll()
+212
View File
@@ -0,0 +1,212 @@
"""Patient card: header, key numbers, charts, dose calendar, alerts feed with ack, safety plan, AI summary."""
from __future__ import annotations
from datetime import date
from html import escape
import streamlit as st
from app.api.schemas import AlertOut, PatientCard
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import charts, state, ui
from dashboard.components import helpers as h
def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
"""Heat-strip: columns = days, rows = scheduled times. Glyphs duplicate colour for colour-blind readers."""
if not grid.days:
return '<div class="mt-dim">Данных о приёме пока нет</div>'
glyph = {"taken": "✓", "missed": "✕", "skipped": "–", "pending": "·"}
label = {"taken": "принял", "missed": "нет ответа", "skipped": "пропустил", "pending": "ожидается"}
cols = f"56px repeat({len(grid.days)}, minmax(18px, 1fr))"
cells = ['<div class="lab"></div>']
cells += [f'<div class="lab">{d.day}</div>' for d in grid.days]
for slot in grid.slots:
cells.append(f'<div class="lab" style="text-align:left">{slot}</div>')
for d in grid.days:
status = grid.cells.get((slot, d))
if status is None:
cells.append('<div class="cell c-none"></div>')
continue
day = f" · день {(d - start).days}" if start else ""
tip = f"{h.short_date(d)}{day}, {slot}: {label[status]}"
cells.append(f'<div class="cell c-{status}" title="{escape(tip)}">{glyph[status]}</div>')
c = grid.counts
legend = (
'<div class="mt-legend">'
f'<span><i style="background:var(--green)"></i>принял · {c["taken"]}</span>'
f'<span><i style="background:var(--yellow)"></i>пропустил · {c["skipped"]}</span>'
f'<span><i style="background:var(--red)"></i>нет ответа · {c["missed"]}</span>'
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>"
)
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}'
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
return (
f'<div class="mt-kpi" style="--kpi-accent:{theme.TONES[tone].solid}"><div class="lbl">{escape(label)}</div>'
f'<div class="val">{value}</div><div class="hint">{escape(hint)}</div></div>'
)
def _score_tile(card: PatientCard, scale_id: str, title: str) -> str:
last, prev = h.last_and_prev(card.series, scale_id)
if last is None:
return _tile(title, '<span class="mt-dim">—</span>', "не проводился", "grey")
delta = ""
if prev is not None and last != prev:
arrow = "↑" if last > prev else "↓"
tone = "red" if last > prev else "green"
delta = f' <span style="font-size:1rem;color:{theme.TONES[tone].ink}">{arrow} с {prev:g}</span>'
sev = h.severity_label(scale_id, last)
return _tile(title, f"{last:g}{delta}", sev, "red" if last > (prev or last) else "blue")
def _header(card: PatientCard, today: date | None) -> None:
p = card.patient
status = h.status_view(p.status_color)
risk = h.risk_view(p.discontinuation_risk)
chips = "".join(ui.chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
meta = (
f"{h.age_label(p.age)} · {chips} · {escape(p.drug or 'без назначения')} · день терапии {p.therapy_day}"
f" · {h.visit_label(p.next_visit, today)}"
)
st.html(
'<div class="mt-head"><div>'
f'<div style="margin-bottom:6px">{ui.badge(status.text, status.tone, status.hint, icon="dot")}</div>'
f'<h1>{escape(p.display_name)}</h1><div class="mt-sub">{meta}</div></div>'
f'<div class="mt-side"><div class="mt-kicker" style="margin-bottom:4px">Риск прерывания терапии</div>'
f"{ui.badge(risk.text, risk.tone, risk.hint)}"
f'<div class="mt-meta">{escape(", ".join(p.discontinuation_risk.factors))}</div>'
"</div></div>"
)
def _ack(alert: AlertOut) -> None:
try:
res = state.client().ack(alert.id)
except ApiError as exc:
st.toast(f"Не удалось подтвердить: {exc.message}", icon="⚠️")
return
state.local_acks()[alert.id] = (res.acked_by or state.client().doctor_id, res.acked_at or state.now() or alert.ts)
st.toast(f"Алерт подтверждён: {alert.reason}", icon="✅")
def _alerts_feed(alerts: list[AlertOut], today: date | None) -> None:
st.html('<div class="mt-panel-title">Алерты</div>')
if not alerts:
st.caption("Сигналов нет")
return
for a in h.sort_alerts(alerts):
tone = h.level_tone(a.level)
acked = a.acked_at is not None
level = h.LEVEL_LABELS.get(a.level, a.level)
status = f"подтверждён {h.when_label(a.acked_at, today)}" if a.acked_at else "открыт"
st.html(
f'<div class="mt-alert t-{tone}{" acked" if acked else ""}">'
f'<div class="when">{theme.TONES[tone].icon} {level} · {escape(h.when_label(a.ts, today))}</div>'
f'<div class="why">{escape(a.reason)}</div>'
f'<div class="when">{escape(status)}</div></div>'
)
if not acked:
st.button(
"Подтвердить",
key=f"ack-{a.id}",
on_click=_ack,
args=(a,),
icon=":material/check:",
type="primary" if a.level == "acute" else "secondary",
help="Врач видел сигнал и принял меры (запись в журнал аудита)",
)
def _summary(text: str) -> None:
st.html('<div class="mt-panel-title">Саммари (ИИ, по структурированным данным)</div>')
body = escape(text) if text else '<span class="mt-dim">Саммари ещё не сформировано</span>'
st.html(
f'<div class="mt-summary">{body}<div class="disc">Сформировано по структурированным данным '
"(шкалы, чек-ины, приём, алерты) без свободного текста пациента. Решения принимает врач.</div></div>"
)
def _safety_plan(plan: list[str] | None) -> None:
st.html('<div class="mt-panel-title">План безопасности</div>')
if not plan:
st.caption("Не составлен. Составляется с пациентом при острых сигналах (Stanley–Brown).")
return
items = "".join(f"<li>{escape(step)}</li>" for step in plan)
st.html(f'<div class="mt-plan"><ol>{items}</ol></div>')
def _prescriptions(card: PatientCard) -> None:
st.html('<div class="mt-panel-title">Назначения</div>')
if not card.prescriptions:
st.caption(card.patient.drug or "Нет активных назначений")
return
for rx in card.prescriptions:
times = ", ".join(rx.schedule.get("times", []))
since = f" · с {h.short_date(rx.start_date)}" if rx.start_date else ""
st.html(
f'<div style="margin-bottom:6px"><b>{escape(rx.drug)} {escape(rx.dose)}</b>'
f'<div class="mt-meta">{escape(times)} · {escape(rx.instructions or "без особых указаний")}{since}</div>'
"</div>"
)
def render(card: PatientCard, today: date | None) -> None:
card = card.model_copy(update={"alerts": h.apply_local_acks(card.alerts, state.local_acks())})
start = card.prescriptions[0].start_date if card.prescriptions else None
_header(card, today)
grid = h.dose_grid(card.doses)
adh = h.adherence_view(card.patient.adherence_7d)
open_alerts = [a for a in card.alerts if a.acked_at is None]
taken = f"{grid.counts['taken']} из {sum(grid.counts[s] for s in ('taken', 'missed', 'skipped'))} доз за курс"
st.html(
'<div class="mt-kpis">'
+ _score_tile(card, "phq9", "PHQ-9 · депрессия")
+ _score_tile(card, "pcl5", "PCL-5 · ПТСР")
+ _tile("Приём за 7 дней", adh.text, taken if grid.days else "", adh.tone)
+ _tile(
"Открытые алерты",
str(len(open_alerts)),
"есть кризисный" if any(a.level == "acute" for a in open_alerts) else "",
"red" if any(a.level == "acute" for a in open_alerts) else ("yellow" if open_alerts else "green"),
)
+ "</div>"
)
main, side = st.columns([1.75, 1], gap="large")
with main:
with st.container(border=True):
c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start)
with col:
if fig is None:
st.caption("Шкала ещё не проводилась")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True):
fig = charts.wellbeing_figure(card.series, start)
if fig is None:
st.caption("Ежедневных чек-инов пока нет")
else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True):
st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start))
with side:
with st.container(border=True):
_alerts_feed(card.alerts, today)
with st.container(border=True):
_summary(card.summary)
with st.container(border=True):
_safety_plan(card.safety_plan)
with st.container(border=True):
_prescriptions(card)
+62
View File
@@ -0,0 +1,62 @@
"""Session-level plumbing shared by all dashboard pages: API client, demo "today", local acks, error banners."""
from __future__ import annotations
import os
from collections.abc import Callable
from datetime import date, datetime
from html import escape
from typing import TypeVar
import streamlit as st
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
from dashboard.components import ui
T = TypeVar("T")
# Short doctor names for the header; no other doctor PII is shown.
DOCTORS = {"doc-smirnova": ("д-р Смирнова Е. А.", "д-ра Смирновой Е. А.")}
PATIENTS_PAGE = "pages/1_Пациенты.py"
CONNECT_PAGE = "pages/1b_Подключить_пациента.py"
@st.cache_resource
def _client(base_url: str | None, doctor_id: str | None) -> ApiClient:
return ApiClient(base_url, doctor_id)
def client() -> ApiClient:
return _client(os.environ.get("API_BASE_URL"), os.environ.get("DOCTOR_ID"))
def doctor_names() -> tuple[str, str]:
did = client().doctor_id
return DOCTORS.get(did, (did, did))
def now() -> datetime | None:
"""Demo clock from the API (ADR-005), so day 13 of the scenario is "today" on screen; None if unknown."""
c = client().clock()
return c.now if c else None
def today() -> date | None:
n = now()
return n.date() if n else None
def local_acks() -> dict[int, tuple[str, datetime]]:
return st.session_state.setdefault("acked_local", {})
def call(fn: Callable[[], T]) -> T | None:
"""Run an API call; on failure show a calm banner instead of a traceback and return None."""
try:
return fn()
except ApiUnavailable as exc:
st.html(ui.banner(f"<b>API недоступен.</b> {escape(exc.message)}. Данные обновятся автоматически.", "grey"))
except ApiError as exc:
st.html(ui.banner(f"<b>Не удалось загрузить данные.</b> {escape(exc.message)}", "grey"))
return None
+126
View File
@@ -0,0 +1,126 @@
"""HTML fragments of the dashboard. Builders return strings (testable); `st.html` renders them.
Every value coming from the API is escaped: patient free text never reaches these views, but the API is still
treated as untrusted input.
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import PatientListItem
from dashboard import theme
from dashboard.components import helpers as h
def chip(text: str) -> str:
return f'<span class="mt-chip">{escape(text)}</span>'
def badge(text: str, tone: str, tip: str = "", icon: str = "") -> str:
tip_attr = f' data-tip="{escape(tip)}" title="{escape(tip)}" tabindex="0"' if tip else ""
cls = " mt-tip" if tip else ""
lead = f'<span class="mt-dot" style="background:{theme.TONES[tone].solid}"></span>' if icon == "dot" else icon
return f'<span class="mt-badge t-{tone}{cls}"{tip_attr}>{lead}{escape(text)}</span>'
def bar(fraction: float | None, tone: str) -> str:
pct = 0 if fraction is None else max(0, min(100, round(fraction * 100)))
return f'<div class="mt-bar"><span style="width:{pct}%;background:{theme.TONES[tone].solid}"></span></div>'
def page_header(title: str, subtitle: str = "", kicker: str = "") -> str:
k = f'<div class="mt-kicker">{escape(kicker)}</div>' if kicker else ""
s = f'<div class="mt-sub">{subtitle}</div>' if subtitle else ""
return f'<div class="mt-head"><div>{k}<h1>{escape(title)}</h1>{s}</div></div>'
def kpi_tiles(k: h.Kpis) -> str:
tiles = [
("Всего пациентов", str(k.total), "Под вашим наблюдением", theme.INK_2),
("Срочно", str(k.red), "Кризисные уведомления", theme.TONES["red"].solid),
("Требуют внимания", str(k.yellow + k.grey), f"Из них только инфо: {k.grey}", theme.TONES["yellow"].solid),
("Без уведомлений", str(k.green), "Продолжают наблюдение", theme.TONES["green"].solid),
]
body = "".join(
f'<div class="mt-kpi" style="--kpi-accent:{accent}"><div class="lbl">{escape(lbl)}</div>'
f'<div class="val">{val}</div><div class="hint">{escape(hint)}</div></div>'
for lbl, val, hint, accent in tiles
)
return f'<div class="mt-kpis">{body}</div>'
LIST_COLUMNS = ["Пациент", "Статус", "Шкалы", "Приём 7 дн.", "Риск прерывания"]
def list_header() -> str:
return '<div class="mt-cols">' + "".join(f"<div>{c}</div>" for c in LIST_COLUMNS) + "</div>"
def _score(value: int | None, trend: str) -> str:
if value is None:
return '<span class="mt-dim">—</span>'
t = h.trend_view(trend)
return (
f'<span class="mt-num">{value}</span> '
f'<span title="{escape(t.hint)}" aria-label="{escape(t.hint)}" '
f'style="color:{theme.TONES[t.tone].ink};font-weight:600">'
f"{t.text}</span>"
)
def patient_row(p: PatientListItem, *, detailed: bool = False) -> str:
"""One row of the patient list; the grid matches `list_header()`."""
status = h.status_view(p.status_color)
chips = "".join(chip(h.MODULE_LABELS.get(m, m)) for m in p.modules)
adh = h.adherence_view(p.adherence_7d)
risk = h.risk_view(p.discontinuation_risk)
details = ""
if detailed:
details = (
'<div class="mt-row-details">'
f'<div><span class="mt-field-label">Терапия</span>{escape(p.drug or "Нет назначения")} · {chips}</div>'
f'<div><span class="mt-field-label">Последнее событие</span>{escape(p.last_event or "Нет событий")}</div>'
'<div><span class="mt-field-label">Факторы риска прерывания</span>'
f"{escape(', '.join(p.discontinuation_risk.factors) or 'Значимых факторов нет')}</div></div>"
)
return (
'<div class="mt-row">'
f'<div class="mt-person"><div class="mt-name">{escape(p.display_name)}</div>'
f'<div class="mt-meta">{h.age_label(p.age)} · день терапии {p.therapy_day}</div></div>'
f'<div class="mt-status">{badge(status.text, status.tone, status.hint, icon="dot")}'
f'<div class="mt-meta">Открытых уведомлений: {p.open_alerts}</div></div>'
f'<div class="mt-scores"><div><span class="mt-scale-label">PHQ-9</span>{_score(p.phq9_last, p.trend)}</div>'
f'<div><span class="mt-scale-label">PCL-5</span>{_score(p.pcl5_last, p.trend)}</div></div>'
'<div class="mt-adherence"><span class="mt-mobile-label">Приём за 7 дней</span>'
f'<span class="mt-num">{adh.text}</span>{bar(p.adherence_7d, adh.tone)}</div>'
'<div class="mt-risk"><span class="mt-mobile-label">Риск прерывания</span>'
f"{badge(risk.text, risk.tone, risk.hint)}</div>"
f"{details}</div>"
)
def workspace_header(doctor: str, page: str) -> str:
return (
'<div class="mt-workspace"><div>Кабинет врача <span aria-hidden="true"> / </span>'
f' {escape(page)}</div><div class="mt-doctor"><span class="mt-doctor-icon" aria-hidden="true">+</span>'
f"<div><strong>{escape(doctor)}</strong><small>Врач-психиатр · демо</small></div></div></div>"
)
def empty_state(title: str, hint: str) -> str:
return f'<div class="mt-empty" role="status"><strong>{escape(title)}</strong><p>{escape(hint)}</p></div>'
def banner(html_body: str, tone: str = "red", pulse: bool = False) -> str:
lead = '<span class="mt-pulse"></span>' if pulse else ""
return f'<div class="mt-banner t-{tone}">{lead}<div>{html_body}</div></div>'
def footer() -> None:
st.html(
'<div class="mt-footer">Прототип на синтетических данных · MedITron · '
"ИИ не ставит диагнозы и не назначает лечение — решения принимает врач</div>"
)
+5
View File
@@ -0,0 +1,5 @@
"""Demo console building blocks (#17): HTTP API helpers, scenario parser, playback runner, Streamlit widgets.
Pages import this package as `console` (Streamlit puts `dashboard/` on sys.path). Modules other than
`widgets` / `chat_ui` are free of Streamlit so they can be unit-tested.
"""
+185
View File
@@ -0,0 +1,185 @@
"""Thin synchronous HTTP client for the demo console (frozen API contract, `app/api/schemas.py`).
The console never imports backend code: it talks to `API_BASE_URL` only. Responses are kept as plain dicts —
the console reads a handful of fields and must survive a backend that still serves fixtures (#12).
Every failure is raised as `ApiError` with a short Russian message, so pages can show it instead of crashing.
"""
from __future__ import annotations
import os
from typing import Any, Protocol
import httpx
DEFAULT_DOCTOR = "doc-smirnova"
DEFAULT_TOKEN = "tok-andrey"
def api_base_url() -> str:
return os.environ.get("API_BASE_URL", "http://localhost:8000").rstrip("/")
def doctor_id() -> str:
return os.environ.get("DEMO_DOCTOR_ID", DEFAULT_DOCTOR)
def fallback_token() -> str:
return os.environ.get("DEMO_PERSONA_TOKEN", DEFAULT_TOKEN)
class ApiError(RuntimeError):
"""Any API failure (network, HTTP status, bad JSON) with a message fit for the presenter."""
class DemoApi(Protocol):
"""What the playback runner needs; `ConsoleApi` implements it, tests use a fake."""
def clock(self) -> dict[str, Any]: ...
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]: ...
def send_event(
self,
token: str,
type_: str,
*,
text: str | None = None,
button: str | None = None,
params: dict[str, Any] | None = None,
) -> list[dict[str, Any]]: ...
def outbox(self, token: str) -> list[dict[str, Any]]: ...
def patients(self, doctor: str) -> list[dict[str, Any]]: ...
class ConsoleApi:
"""Client for `/api/v1`. Short timeouts everywhere except clock moves, which replay the scheduler."""
def __init__(self, base_url: str | None = None, *, timeout: float = 5.0, client: httpx.Client | None = None):
self.base_url = (base_url or api_base_url()).rstrip("/")
self._timeout = timeout
self._client = client or httpx.Client(timeout=timeout)
# ------------------------------------------------------------------ plumbing
def _request(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any:
url = f"{self.base_url}{path}"
try:
resp = self._client.request(method, url, timeout=timeout or self._timeout, **kwargs)
except httpx.TimeoutException as exc:
raise ApiError(f"{method} {path}: API не ответил вовремя") from exc
except httpx.HTTPError as exc:
raise ApiError(f"{method} {path}: API недоступен ({type(exc).__name__})") from exc
if resp.status_code >= 400:
raise ApiError(f"{method} {path}: HTTP {resp.status_code}")
try:
return resp.json()
except ValueError as exc:
raise ApiError(f"{method} {path}: ответ не JSON") from exc
def close(self) -> None:
self._client.close()
# ------------------------------------------------------------------ service
def health(self) -> bool:
try:
return self._request("GET", "/health", timeout=2).get("status") == "ok"
except (ApiError, AttributeError):
return False
# ------------------------------------------------------------------ demo controls (ADR-005)
def personas(self) -> list[dict[str, Any]]:
return list(self._request("GET", "/api/v1/demo/personas"))
def clock(self) -> dict[str, Any]:
return dict(self._request("GET", "/api/v1/demo/clock"))
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]:
# The backend replays the scheduler up to the new time inside this call: allow it time.
body = {"days": days, "hours": hours}
return dict(self._request("POST", "/api/v1/demo/clock/advance", json=body, timeout=60))
def reset(self) -> dict[str, Any]:
return dict(self._request("POST", "/api/v1/demo/reset", timeout=60))
# ------------------------------------------------------------------ patient dialog
def send_event(
self,
token: str,
type_: str,
*,
text: str | None = None,
button: str | None = None,
params: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
body: dict[str, Any] = {"type": type_, "params": params or {}}
if text is not None:
body["text"] = text
if button is not None:
body["button"] = button
data = self._request("POST", f"/api/v1/dialog/{token}/events", json=body, timeout=30)
return list(data.get("replies", []))
def outbox(self, token: str) -> list[dict[str, Any]]:
return list(self._request("GET", f"/api/v1/dialog/{token}/outbox").get("replies", []))
def history(self, token: str, limit: int = 100) -> list[dict[str, Any]]:
return list(self._request("GET", f"/api/v1/dialog/{token}/history", params={"limit": limit}))
# ------------------------------------------------------------------ doctor side
def patients(self, doctor: str) -> list[dict[str, Any]]:
return list(self._request("GET", f"/api/v1/doctors/{doctor}/patients"))
def alerts(self, doctor: str, since_id: int = -1, open_only: bool = False) -> list[dict[str, Any]]:
params = {"since_id": since_id, "open_only": str(open_only).lower()}
return list(self._request("GET", f"/api/v1/doctors/{doctor}/alerts", params=params))
def connect(self, body: dict[str, Any]) -> dict[str, Any]:
"""`POST /patients/connect` (ConnectPatientIn) → {token, code, deep_link}."""
return dict(self._request("POST", "/api/v1/patients/connect", json=body, timeout=30))
def ack(self, alert_id: int, doctor: str) -> dict[str, Any]:
return dict(self._request("POST", f"/api/v1/alerts/{alert_id}/ack", json={"doctor_id": doctor}))
def personas_or_fallback(api: ConsoleApi) -> tuple[list[dict[str, Any]], str | None]:
"""Demo personas for the selector, degrading step by step so the console always has someone to talk as.
1. `GET /demo/personas` (patients with a scenario file);
2. the demo doctor's patient list (real tokens; no scenario file yet, e.g. before #18 lands);
3. a single persona with the token from `DEMO_PERSONA_TOKEN` (fixtures use `tok-andrey`).
Returns the personas and a note for the presenter when a fallback was used.
"""
try:
personas = [p for p in api.personas() if isinstance(p, dict) and p.get("token")]
if personas:
return personas, None
error = "API не вернул персон со сценарием"
except ApiError as exc:
error = str(exc)
try:
patients = [p for p in api.patients(doctor_id()) if isinstance(p, dict) and p.get("token")]
except ApiError:
patients = []
if patients:
# The demo hero first: the scenario file is guessed from the name later (`default_scenario_path`).
patients.sort(key=lambda p: not str(p.get("display_name", "")).startswith("Андрей"))
personas = [
{
"token": p["token"],
"display_name": p.get("display_name") or p["token"],
"therapy_day": p.get("therapy_day", 0),
"scenario": None,
}
for p in patients
]
return personas, f"{error}; показаны пациенты врача"
token = fallback_token()
fallback = {
"token": token,
"display_name": "Андрей С." if token == DEFAULT_TOKEN else token,
"therapy_day": 0,
"scenario": None,
}
return [fallback], f"{error}; используется токен из окружения"
+81
View File
@@ -0,0 +1,81 @@
"""In-memory transcript of the patient chat shown by the console (kept in Streamlit session state).
Entries are plain dicts: `{"dir": "out", "text", "kind", "buttons"}` for bot replies (the `Reply` contract)
and `{"dir": "in", "text", "pressed": bool}` for what the patient sent.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
MAX_ENTRIES = 400
def normalize_reply(raw: dict[str, Any]) -> dict[str, Any]:
"""Coerce an API reply into a safe shape: missing fields get defaults, malformed buttons are dropped."""
rows: list[list[dict[str, str]]] = []
for row in raw.get("buttons") or []:
if not isinstance(row, list):
continue
clean = [
{"label": str(b.get("label", "")), "data": str(b.get("data", ""))}
for b in row
if isinstance(b, dict) and b.get("data") is not None
]
if clean:
rows.append(clean)
return {
"dir": "out",
"text": str(raw.get("text") or ""),
"kind": str(raw.get("kind") or "normal"),
"buttons": rows,
}
class ChatLog:
"""Wraps a list owned by the caller (session state), so every page sees the same transcript."""
def __init__(self, entries: list[dict[str, Any]] | None = None):
self.entries: list[dict[str, Any]] = entries if entries is not None else []
def _trim(self) -> None:
if len(self.entries) > MAX_ENTRIES:
del self.entries[: len(self.entries) - MAX_ENTRIES]
def add_in(self, text: str, *, pressed: bool = False) -> None:
self.entries.append({"dir": "in", "text": text, "pressed": pressed})
self._trim()
def add_replies(self, replies: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
added = [normalize_reply(r) for r in replies if isinstance(r, dict)]
self.entries.extend(added)
self._trim()
return added
def add_history(self, messages: Iterable[dict[str, Any]]) -> None:
"""Prefill from `GET /dialog/{token}/history` (MessageOut: no buttons)."""
for m in messages:
if not isinstance(m, dict):
continue
if m.get("direction") == "in":
self.entries.append({"dir": "in", "text": str(m.get("text", "")), "pressed": False})
else:
self.entries.append(normalize_reply({"text": m.get("text"), "kind": m.get("kind")}))
self._trim()
def recent_replies(self, n: int = 10) -> list[dict[str, Any]]:
"""Last `n` bot replies, oldest first."""
outs = [e for e in self.entries if e.get("dir") == "out"]
return outs[-n:] if n > 0 else []
def last_buttons_index(self) -> int | None:
"""Index of the newest bot reply that carries buttons — only its keyboard is clickable in the web chat."""
for i in range(len(self.entries) - 1, -1, -1):
e = self.entries[i]
if e.get("dir") == "out" and e.get("buttons"):
return i
return None
def clear(self) -> None:
self.entries.clear()
+312
View File
@@ -0,0 +1,312 @@
"""Scenario playback over the HTTP API (#17): runs `data/synthetic/<persona>.jsonl` step by step.
Time handling. A scenario day is a calendar date on the demo clock. When day N is played, its date is
derived from the last played day M: date(N) = date(M) + (N − M); with nothing played yet (or if that date
is already behind the clock, e.g. after manual «Следующий день») `advance_to HH:MM` goes to the next
occurrence of HH:MM. The clock only moves forward (`/demo/clock/advance` replays the scheduler), so a target
already in the past is skipped with a warning instead of failing.
Failure handling. Every step is wrapped: an error stops playback, the position is kept in `PlaybackState`
and the page offers «повторить шаг» / «пропустить шаг» / «продолжить вручную». The demo never crashes.
"""
from __future__ import annotations
import time as _time
from collections.abc import Callable, Iterator, Sequence
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from .api import DEFAULT_DOCTOR, ApiError, DemoApi
from .chatlog import ChatLog
from .scenario import Action, DayScript, Scenario, parse_hhmm
# Virtual time keeps ticking between steps: a target a few minutes behind "now" counts as reached.
TOLERANCE = timedelta(minutes=5)
PRESS_WINDOW = 10
StepStatus = Literal["ok", "warn", "fail", "info"]
# ------------------------------------------------------------------------------------------------ pure helpers
def _norm(s: str) -> str:
return " ".join(s.casefold().replace("ё", "е").split())
def resolve_press(replies: Sequence[dict[str, Any]], label: str, window: int = PRESS_WINDOW) -> tuple[str, str] | None:
"""Find the button to press for a scenario `press` step.
Searches the last `window` replies newest first; inside a reply an exact (case/ё-insensitive) label match
wins over a substring match. Returns `(data, label)` or None. Button `data` is dynamic (dose ids), so
scenarios address buttons by label.
"""
needle = _norm(label)
if not needle:
return None
for reply in reversed(list(replies)[-window:] if window > 0 else []):
buttons = [b for row in reply.get("buttons") or [] for b in row if isinstance(b, dict)]
for exact in (True, False):
for b in buttons:
text = _norm(str(b.get("label", "")))
if (text == needle) if exact else (needle in text):
return str(b.get("data", "")), str(b.get("label", ""))
return None
def parse_clock_now(clock: dict[str, Any]) -> datetime:
"""`ClockOut.now` (ISO, tz-aware) → datetime; raises ValueError on garbage."""
raw = clock.get("now")
if not isinstance(raw, str):
raise ValueError("часы API не вернули поле now")
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
def target_datetime(now: datetime, hhmm: str, day_date: date | None) -> datetime:
"""Moment to move the clock to for `advance_to HH:MM` (see module docstring for the rules)."""
h, m = parse_hhmm(hhmm)
if day_date is None:
cand = now.replace(hour=h, minute=m, second=0, microsecond=0)
if cand < now - TOLERANCE:
cand += timedelta(days=1)
return cand
return datetime.combine(day_date, time(h, m), tzinfo=now.tzinfo)
def hours_until(now: datetime, target: datetime) -> float | None:
"""Hours to advance (≥ 0), 0 within the tolerance window, None if the target is clearly in the past."""
delta = target - now
if delta >= timedelta(0):
return delta.total_seconds() / 3600
if delta >= -TOLERANCE:
return 0.0
return None
def planned_date(dates: dict[int, date], day: int, today: date) -> date | None:
"""Calendar date for scenario `day` from already played days, or None to use "next occurrence" rules."""
if day in dates:
cand = dates[day]
else:
earlier = [d for d in dates if d < day]
if not earlier:
return None
prev = max(earlier)
cand = dates[prev] + timedelta(days=day - prev)
return cand if cand >= today else None
# ------------------------------------------------------------------------------------------------ runner
@dataclass
class StepLog:
day: int
index: int # 0-based step index inside the day; -1 for day-level messages
status: StepStatus
message: str
action: Action | None = None
def render(self) -> str:
icon = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "ℹ️"}[self.status]
step = f"шаг {self.index + 1}: " if self.index >= 0 else ""
return f"{icon} день {self.day}, {step}{self.message}"
@dataclass
class Resume:
day: int
step: int
until: int
@dataclass
class PlaybackState:
"""Per-persona playback memory kept in session state; survives an interrupted Streamlit run."""
dates: dict[int, date] = field(default_factory=dict) # scenario day → calendar date it was played on
done: list[int] = field(default_factory=list) # fully played days
failed: Resume | None = None # where playback stopped on an error
position: Resume | None = None # step currently being executed (left set if the run was interrupted)
log: list[str] = field(default_factory=list)
@property
def last_done(self) -> int | None:
return max(self.done) if self.done else None
def reset(self) -> None:
self.dates.clear()
self.done.clear()
self.failed = None
self.position = None
self.log.clear()
class StepFailed(Exception):
pass
class PlaybackRunner:
def __init__(
self,
api: DemoApi,
token: str,
chat: ChatLog,
state: PlaybackState,
*,
doctor: str = DEFAULT_DOCTOR,
delay: float = 0.0,
press_retries: int = 3,
retry_wait: float = 0.7,
sleep: Callable[[float], None] = _time.sleep,
):
self.api = api
self.token = token
self.chat = chat
self.state = state
self.doctor = doctor
self.delay = delay
self.press_retries = press_retries
self.retry_wait = retry_wait
self.sleep = sleep
# ---------------------------------------------------------------- helpers
def drain(self) -> list[dict[str, Any]]:
"""Pull system-initiated replies (reminders, check-ins) into the chat."""
return self.chat.add_replies(self.api.outbox(self.token))
def _now(self) -> datetime:
return parse_clock_now(self.api.clock())
@staticmethod
def _preview(replies: list[dict[str, Any]]) -> str:
if not replies:
return ""
text = replies[-1].get("text", "").replace("\n", " ")
more = f" (+{len(replies) - 1})" if len(replies) > 1 else ""
return f" → «{text[:70]}{'…' if len(text) > 70 else ''}»{more}"
def next_day(self, scenario: Scenario) -> DayScript | None:
last = self.state.last_done
return next((d for d in scenario.days if last is None or d.day > last), None)
# ---------------------------------------------------------------- one step
def _step(self, day: DayScript, action: Action, day_date: list[date | None]) -> tuple[StepStatus, str]:
if action.kind == "advance_to":
now = self._now()
target = target_datetime(now, action.value, day_date[0])
if day_date[0] is None:
day_date[0] = target.date()
self.state.dates[day.day] = target.date()
hours = hours_until(now, target)
if hours is None:
return "warn", f"{action.describe()}: время уже прошло ({now:%d.%m %H:%M}), пропускаю"
if hours > 0:
self.api.advance(hours=hours)
got = self.drain()
return "ok", f"{action.describe()} ({target:%d.%m %H:%M}){self._preview(got)}"
if action.kind == "press":
found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value)
for _ in range(self.press_retries):
if found is not None:
break
# The reply may still be on its way (scheduler tick): wait and drain the outbox again.
self.sleep(self.retry_wait)
self.drain()
found = resolve_press(self.chat.recent_replies(PRESS_WINDOW), action.value)
if found is None:
raise StepFailed(f"кнопка «{action.value}» не найдена в последних {PRESS_WINDOW} сообщениях")
data, label = found
self.chat.add_in(label, pressed=True)
got = self.chat.add_replies(self.api.send_event(self.token, "button", button=data))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
if action.kind == "text":
self.chat.add_in(action.value)
got = self.chat.add_replies(self.api.send_event(self.token, "text", text=action.value))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
if action.kind == "start":
params = {"code": action.value} if action.value else {}
self.chat.add_in("/start", pressed=True)
got = self.chat.add_replies(self.api.send_event(self.token, "start", params=params))
got += self.drain()
return "ok", f"{action.describe()}{self._preview(got)}"
# expect_color is a soft check: a mismatch is shown to the presenter but never stops the demo.
try:
patients = self.api.patients(self.doctor)
except ApiError as exc:
return "warn", f"{action.describe()}: не удалось проверить ({exc})"
me = next((p for p in patients if isinstance(p, dict) and p.get("token") == self.token), None)
if me is None:
return "warn", f"{action.describe()}: пациента нет в списке врача"
actual = me.get("status_color")
if actual == action.value:
return "ok", f"{action.describe()}: совпало"
return "warn", f"{action.describe()}: у врача сейчас {actual}"
# ---------------------------------------------------------------- days
def run(self, scenario: Scenario, from_day: int, from_step: int, until_day: int) -> Iterator[StepLog]:
"""Play days `from_day..until_day` (those present in the scenario), the first one from `from_step`."""
days = [d for d in scenario.days if from_day <= d.day <= until_day]
if not days:
yield StepLog(from_day, -1, "info", "в сценарии нет дней для проигрывания")
return
self.state.failed = None
for n, day in enumerate(days):
start = from_step if n == 0 else 0
try:
today = self._now().date()
except (ApiError, ValueError) as exc:
self._fail(day.day, max(start, 0), until_day)
yield StepLog(day.day, -1, "fail", f"часы API недоступны: {exc}")
return
day_date: list[date | None] = [planned_date(self.state.dates, day.day, today)]
if day_date[0] is not None:
self.state.dates[day.day] = day_date[0]
yield StepLog(day.day, -1, "info", f"начинаю день {day.day}" + (f": {day.note}" if day.note else ""))
for i in range(start, len(day.actions)):
action = day.actions[i]
self.state.position = Resume(day.day, i, until_day)
try:
status, message = self._step(day, action, day_date)
except Exception as exc: # robustness first: any error pauses playback, never crashes the page
self._fail(day.day, i, until_day)
reason = str(exc) or type(exc).__name__
yield StepLog(day.day, i, "fail", f"{action.describe()}: {reason}", action)
return
yield StepLog(day.day, i, status, message, action)
if self.delay > 0 and i < len(day.actions) - 1:
self.sleep(self.delay)
if day.day not in self.state.dates:
self.state.dates[day.day] = today
if day.day not in self.state.done:
self.state.done.append(day.day)
self.state.position = None
yield StepLog(day.day, -1, "ok", f"день {day.day} проигран")
if self.delay > 0 and n < len(days) - 1:
self.sleep(self.delay)
def _fail(self, day: int, step: int, until: int) -> None:
self.state.failed = Resume(day, step, until)
self.state.position = None
def play_day(self, scenario: Scenario, day: int) -> Iterator[StepLog]:
return self.run(scenario, day, 0, day)
def scroll_to(self, scenario: Scenario, day: int) -> Iterator[StepLog]:
"""Play every not yet played day up to and including `day`."""
nxt = self.next_day(scenario)
if nxt is None or nxt.day > day:
return iter([StepLog(day, -1, "info", f"дни до {day} уже проиграны")])
return self.run(scenario, nxt.day, 0, day)
def resume(self, scenario: Scenario, *, skip: bool) -> Iterator[StepLog]:
"""Continue after a failure (or an interrupted run): retry the failed step or skip it."""
point = self.state.failed or self.state.position
if point is None:
return iter([StepLog(0, -1, "info", "нечего продолжать")])
step = point.step + (1 if skip else 0)
return self.run(scenario, point.day, step, point.until)
+106
View File
@@ -0,0 +1,106 @@
"""Scenario playback panel of the demo console (#17): day list, «Проиграть день N», «Прокрутить до дня N»."""
from __future__ import annotations
from typing import Any
import streamlit as st
from . import widgets as w
from .playback import PlaybackState
from .scenario import Scenario
def _forget_upload(token: str) -> None:
st.session_state.pop(f"console_scenario_text:{token}", None)
st.session_state.pop(f"console_up:{token}", None) # resets the uploader widget too
def _scenario_source_controls(persona: dict[str, Any], source: str) -> None:
token = persona["token"]
with st.expander(f"Файл сценария: {source}"):
st.text_input(
"Путь в репозитории",
key=f"console_scenario_path:{token}",
placeholder=str(persona.get("scenario") or "data/synthetic/andrey.jsonl"),
)
upload = st.file_uploader("…или загрузить .jsonl", type=["jsonl", "json", "txt"], key=f"console_up:{token}")
if upload is not None:
try:
st.session_state[f"console_scenario_text:{token}"] = upload.getvalue().decode("utf-8")
except UnicodeDecodeError:
st.error("Файл не в UTF-8")
if st.session_state.get(f"console_scenario_text:{token}"):
st.button("Забыть загруженный файл", on_click=_forget_upload, args=(token,))
def _day_table(scenario: Scenario, state: PlaybackState, next_day: int | None) -> None:
rows = []
for d in scenario.days:
mark = "✅" if d.day in state.done else ("▶️" if d.day == next_day else "")
rows.append({"": mark, "День": d.day, "Шагов": len(d.actions), "Подсказка ведущему": d.note})
st.dataframe(rows, hide_index=True, width="stretch", height=min(38 + 35 * len(rows), 330))
def _clear_failure(state: PlaybackState) -> None:
state.failed = None
state.position = None
def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"]
scenario, source = w.scenario_for(persona)
state = w.playback_state(token)
st.subheader("🎬 Сценарий демо")
_scenario_source_controls(persona, source)
if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
for err in scenario.errors:
st.write(f"- {err}")
if not scenario.days:
st.info("Сценарий не загружен. Демо можно вести вручную: чат слева и кнопки часов сверху.")
return
delay = st.slider("Пауза между шагами, с", 0.0, 3.0, 1.0, 0.25, key=f"console_delay:{token}")
runner = w.make_runner(persona, delay=delay)
nxt = runner.next_day(scenario)
_day_table(scenario, state, nxt.day if nxt else None)
numbers = scenario.day_numbers
default = numbers.index(nxt.day) if nxt else len(numbers) - 1
day = st.selectbox("День", numbers, index=default, key=f"console_day:{token}", format_func=lambda n: f"день {n}")
c1, c2, c3 = st.columns(3)
action: tuple[str, Any] | None = None
if c1.button(f"▶ Проиграть день {day}", type="primary", width="stretch"):
action = ("Проигрываю день", runner.play_day(scenario, day))
if c2.button(f"⏭ Прокрутить до дня {day}", width="stretch", help="Быстро, без пауз: все непроигранные дни"):
runner.delay = 0.0
action = (f"Прокрутка до дня {day}", runner.scroll_to(scenario, day))
if c3.button(
f"▶ Следующий: день {nxt.day}" if nxt else "Все дни проиграны",
width="stretch",
disabled=nxt is None,
):
assert nxt is not None
action = ("Проигрываю день", runner.play_day(scenario, nxt.day))
point = state.failed or state.position
if point is not None and action is None:
what = "остановлено на ошибке" if state.failed else "прервано"
st.warning(f"Проигрывание {what}: день {point.day}, шаг {point.step + 1}.")
r1, r2, r3 = st.columns(3)
if r1.button("🔁 Повторить шаг", width="stretch"):
action = ("Продолжаю", runner.resume(scenario, skip=False))
if r2.button("⏩ Пропустить шаг", width="stretch"):
action = ("Продолжаю", runner.resume(scenario, skip=True))
r3.button("✋ Продолжить вручную", width="stretch", on_click=_clear_failure, args=(state,))
if action is not None:
title, logs = action
w.execute(logs, state, title=title)
st.session_state["console_log_open"] = True
st.rerun() # refresh the chat and the clock with what playback produced
if state.log:
with st.expander("Журнал последнего проигрывания", expanded=st.session_state.pop("console_log_open", False)):
st.text("\n".join(state.log[-80:]))
+180
View File
@@ -0,0 +1,180 @@
"""Demo scenario files `data/synthetic/<persona>.jsonl` (format agreed with #18).
One JSON object per line: `{"day": <therapy day>, "note": "<presenter hint>", "actions": [...]}` where an action
is exactly one of `{"advance_to": "HH:MM"}`, `{"press": "<label substring>"}`, `{"text": "<message>"}`,
`{"expect_color": "green|yellow|red|grey"}`. The console also accepts `{"start": "<connect code or empty>"}`
(sends a `start` event) so a scenario can open the dialog itself.
The parser is lenient on purpose: a broken line or action is reported and skipped, the rest still plays —
a typo in the scenario must not take the live demo down.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
ActionKind = Literal["advance_to", "press", "text", "expect_color", "start"]
ACTION_KINDS: tuple[ActionKind, ...] = ("advance_to", "press", "text", "expect_color", "start")
COLORS = ("green", "yellow", "red", "grey")
_HHMM = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
REPO_ROOT = Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class Action:
kind: ActionKind
value: str
def describe(self) -> str:
"""Short Russian description for the step log."""
return {
"advance_to": f"⏩ часы → {self.value}",
"press": f"🔘 нажать «{self.value}»",
"text": f"💬 написать «{self.value}»",
"expect_color": f"🎯 ожидаем цвет {self.value}",
"start": "🚀 начать диалог" + (f" (код {self.value})" if self.value else ""),
}[self.kind]
@dataclass(frozen=True)
class DayScript:
day: int
note: str
actions: tuple[Action, ...]
@dataclass
class Scenario:
days: list[DayScript] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def day(self, number: int) -> DayScript | None:
return next((d for d in self.days if d.day == number), None)
@property
def day_numbers(self) -> list[int]:
return [d.day for d in self.days]
def parse_hhmm(value: str) -> tuple[int, int]:
"""'8:05' / '08:05' → (8, 5); raises ValueError on anything else."""
m = _HHMM.match(value.strip())
if m is None:
raise ValueError(f"время должно быть в формате ЧЧ:ММ, получено «{value}»")
return int(m.group(1)), int(m.group(2))
def parse_action(raw: Any) -> Action:
"""Validate one action object; raises ValueError with a Russian message."""
if not isinstance(raw, dict) or len(raw) != 1:
raise ValueError(f"действие должно быть объектом с одним ключом, получено {raw!r}")
((kind, value),) = raw.items()
if kind not in ACTION_KINDS:
raise ValueError(f"неизвестное действие «{kind}»")
if value is None and kind == "start":
value = ""
if not isinstance(value, str):
raise ValueError(f"значение «{kind}» должно быть строкой")
value = value.strip()
if kind == "advance_to":
h, m = parse_hhmm(value)
value = f"{h:02d}:{m:02d}"
elif kind == "expect_color":
value = value.lower()
if value not in COLORS:
raise ValueError(f"цвет должен быть одним из {', '.join(COLORS)}, получено «{value}»")
elif kind in ("press", "text") and not value:
raise ValueError(f"пустое значение «{kind}»")
return Action(kind, value) # type: ignore[arg-type]
def parse_scenario(text: str) -> Scenario:
"""Parse JSONL text. Days are returned sorted; duplicates keep the first occurrence."""
scenario = Scenario()
seen: set[int] = set()
for lineno, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line or line.startswith("//") or line.startswith("#"):
continue
where = f"строка {lineno}"
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
scenario.errors.append(f"{where}: не JSON ({exc.msg})")
continue
if not isinstance(obj, dict):
scenario.errors.append(f"{where}: ожидается объект")
continue
day = obj.get("day")
if isinstance(day, bool) or not isinstance(day, int) or day < 0:
scenario.errors.append(f"{where}: поле day должно быть целым ≥ 0")
continue
if day in seen:
scenario.errors.append(f"{where}: день {day} повторяется, строка пропущена")
continue
raw_actions = obj.get("actions", [])
if not isinstance(raw_actions, list):
scenario.errors.append(f"{where}: actions должно быть списком")
continue
actions: list[Action] = []
for i, raw in enumerate(raw_actions, start=1):
try:
actions.append(parse_action(raw))
except ValueError as exc:
scenario.errors.append(f"{where}, день {day}, действие {i}: {exc} — пропущено")
note = obj.get("note", "")
seen.add(day)
scenario.days.append(DayScript(day=day, note=str(note) if note is not None else "", actions=tuple(actions)))
scenario.days.sort(key=lambda d: d.day)
return scenario
def resolve_path(path: str | Path, root: Path = REPO_ROOT) -> Path:
p = Path(path)
return p if p.is_absolute() else root / p
def load_scenario(path: str | Path, root: Path = REPO_ROOT) -> Scenario:
"""Read and parse a scenario file; a missing/unreadable file yields an empty scenario with an error."""
full = resolve_path(path, root)
try:
text = full.read_text(encoding="utf-8")
except FileNotFoundError:
return Scenario(errors=[f"файл сценария не найден: {path}"])
except OSError as exc:
return Scenario(errors=[f"не удалось прочитать {path}: {type(exc).__name__}"])
return parse_scenario(text)
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z", "и": "i",
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "shch", "ъ": "", "ы": "y", "ь": "",
"э": "e", "ю": "yu", "я": "ya",
} # fmt: skip
def persona_slug(display_name: str) -> str:
"""'Андрей С.' → 'andrey': the backend names scenario files by the transliterated first name (#12)."""
first = (display_name.split() or [""])[0].lower()
return "".join(_TRANSLIT.get(ch, ch if ch.isascii() and ch.isalnum() else "") for ch in first)
def default_scenario_path(persona: dict[str, Any], root: Path = REPO_ROOT) -> str | None:
"""Scenario path from the persona, else `data/synthetic/<slug>.jsonl` guessed from the name or `tok-<slug>`."""
if persona.get("scenario"):
return str(persona["scenario"])
token = str(persona.get("token", ""))
slugs = [persona_slug(str(persona.get("display_name", "")))]
if token.startswith("tok-"):
slugs.append(token.removeprefix("tok-"))
for slug in filter(None, slugs):
guess = f"data/synthetic/{slug}.jsonl"
if resolve_path(guess, root).exists():
return guess
return None
+455
View File
@@ -0,0 +1,455 @@
"""Streamlit widgets shared by the demo console and the split screen (#17).
Robustness rules for the live demo: every API call goes through `ConsoleApi` (raises `ApiError` only),
callbacks never raise — errors are queued as flash messages and shown on the next render — and the chat
transcript / playback progress live in `st.session_state`, so both pages share them within one browser tab.
"""
from __future__ import annotations
import contextlib
import os
from datetime import datetime
from typing import Any
import streamlit as st
from .api import ApiError, ConsoleApi, api_base_url, doctor_id, personas_or_fallback
from .chatlog import ChatLog
from .playback import PlaybackRunner, PlaybackState, StepLog, parse_clock_now, target_datetime
from .scenario import Scenario, default_scenario_path, load_scenario, parse_scenario
COLOR_EMOJI = {"red": "🔴", "yellow": "🟡", "green": "🟢", "grey": "⚪"}
LEVEL_EMOJI = {"acute": "🔴", "high": "🟠", "moderate": "🟡", "info": "⚪"}
WEEKDAYS = ("пн", "вт", "ср", "чт", "пт", "сб", "вс")
POLL_SECONDS = 2
# ------------------------------------------------------------------------------------------------ state
@st.cache_resource
def _api_for(base_url: str) -> ConsoleApi:
return ConsoleApi(base_url)
def get_api() -> ConsoleApi:
return _api_for(api_base_url())
def chat_for(token: str) -> ChatLog:
key = f"console_chat:{token}"
if key not in st.session_state:
entries: list[dict[str, Any]] = []
st.session_state[key] = entries
with contextlib.suppress(ApiError): # history is a nice-to-have: the chat may start empty
ChatLog(entries).add_history(get_api().history(token))
return ChatLog(st.session_state[key])
def playback_state(token: str) -> PlaybackState:
key = f"console_playback:{token}"
if key not in st.session_state:
st.session_state[key] = PlaybackState()
return st.session_state[key]
def flash(message: str, level: str = "error") -> None:
"""Report a problem from a callback: a toast now plus a persistent note shown by `show_flash`."""
icon = {"error": "❌", "warning": "⚠️", "success": "✅"}.get(level, "ℹ️")
# Outside a script run context (tests, threads) a toast may fail — the persistent note below is enough.
with contextlib.suppress(Exception):
st.toast(message, icon=icon)
st.session_state["console_last_note"] = (level, message)
def show_flash(container: Any = None) -> None:
"""Last error/notice of the session, kept until the next one (does not vanish with the 2 s poll)."""
note = st.session_state.get("console_last_note")
if not note:
return
level, message = note
where = container or st.sidebar
{"error": where.error, "warning": where.warning, "success": where.success}.get(level, where.info)(message)
def forget_demo_state() -> None:
"""After «Сброс демо»: drop transcripts, playback progress and the alert baseline of every persona."""
for key in list(st.session_state.keys()):
if str(key).startswith(("console_chat:", "console_playback:", "console_alerts_")):
del st.session_state[key]
# ------------------------------------------------------------------------------------------------ persona
@st.cache_data(ttl=15, show_spinner=False)
def _personas(base_url: str) -> tuple[list[dict[str, Any]], str | None]:
return personas_or_fallback(get_api())
def select_persona(container: Any = None) -> dict[str, Any]:
"""Persona selector (sidebar by default); the choice is shared by all console pages."""
where = container or st.sidebar
personas, error = _personas(get_api().base_url)
if error:
where.caption(f"⚠️ {error}")
tokens = [p["token"] for p in personas]
current = st.session_state.get("console_token")
index = tokens.index(current) if current in tokens else 0
labels = {p["token"]: f"{p.get('display_name') or p['token']} ({p['token'][:12]})" for p in personas}
token = where.selectbox("Пациент демо", tokens, index=index, format_func=lambda t: labels.get(t, t))
st.session_state["console_token"] = token
return next(p for p in personas if p["token"] == token)
# Synthetic demo hero (docs/product/01_demo_persona.md); the SNILS is fake and used only to derive the token.
DEMO_HERO = {
"display_name": "Андрей С.",
"birth_year": 1992,
"sex": "m",
"snils": "000-000-017 17",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00"],
"instructions": "1 раз в сутки утром после еды",
"modules": ["veteran", "depression"],
}
def _connect_hero() -> None:
name = st.session_state.get("console_hero_name") or DEMO_HERO["display_name"]
body = {**DEMO_HERO, "doctor_id": doctor_id(), "display_name": name}
try:
out = get_api().connect(body)
except ApiError as exc:
hint = " — пациент уже подключён, выберите его в списке" if "409" in str(exc) else ""
flash(f"Подключение не выполнено: {exc}{hint}", "warning" if hint else "error")
return
token = str(out.get("token", ""))
st.session_state["console_token"] = token
st.session_state[f"console_code:{token}"] = str(out.get("code", ""))
_personas.clear()
flash(f"Пациент подключён, код {out.get('code', '')}. Нажмите «Начать» в чате.", "success")
def render_connect_hero(container: Any = None) -> None:
"""Fallback when the demo seed is missing: connect the synthetic hero through the doctor API."""
where = container or st.sidebar
with where.expander("Подключить демо-пациента"):
st.caption("Если сид демо не загружен: врач подключает Андрея (синтетика) — как на приёме.")
st.text_input("Имя", value="Андрей С.", key="console_hero_name")
st.button("Подключить", key="console-connect", on_click=_connect_hero)
# ------------------------------------------------------------------------------------------------ clock
def _drain_quiet(api: ConsoleApi, token: str) -> None:
try:
chat_for(token).add_replies(api.outbox(token))
except ApiError as exc:
flash(f"Не удалось забрать входящие: {exc}", "warning")
def _advance(token: str, days: float = 0, hours: float = 0) -> None:
api = get_api()
try:
api.advance(days=days, hours=hours)
except ApiError as exc:
flash(f"Часы не сдвинулись: {exc}")
return
_drain_quiet(api, token)
def _advance_to(token: str, hhmm: str) -> None:
api = get_api()
try:
now = parse_clock_now(api.clock())
hours = (target_datetime(now, hhmm, None) - now).total_seconds() / 3600
if hours > 0:
api.advance(hours=hours)
except (ApiError, ValueError) as exc:
flash(f"Часы не сдвинулись: {exc}")
return
_drain_quiet(api, token)
def _reset() -> None:
try:
get_api().reset()
except ApiError as exc:
flash(f"Сброс не выполнен: {exc}")
return
forget_demo_state()
_personas.clear() # the seed may be recreated on reset
flash("Демо сброшено: часы вернулись к реальному времени, чат и прогресс сценария очищены.", "success")
def _therapy_day(api: ConsoleApi, persona: dict[str, Any]) -> int | None:
try:
me = next((p for p in api.patients(doctor_id()) if p.get("token") == persona["token"]), None)
except ApiError:
me = None
day = (me or persona).get("therapy_day")
return day if isinstance(day, int) else None
def format_now(now: datetime) -> str:
return f"{WEEKDAYS[now.weekday()]} {now:%d.%m.%Y} · {now:%H:%M}"
def render_clock_bar(persona: dict[str, Any], *, key: str, with_reset: bool = True) -> None:
"""Virtual date/time, therapy day and the time controls (ADR-005)."""
api = get_api()
token = persona["token"]
info, *buttons = st.columns([3.2, 1, 1.2, 1.2, 1.3, 1.2] if with_reset else [3.2, 1, 1.2, 1.2, 1.3])
try:
clock = api.clock()
now = parse_clock_now(clock)
day = _therapy_day(api, persona)
mode = "виртуальные часы" if clock.get("virtual") else "реальное время"
info.markdown(f"### 🕒 {format_now(now)}")
info.caption(f"День терапии: **{day if day is not None else '—'}** · {mode}")
except (ApiError, ValueError) as exc:
info.warning(f"Часы недоступны: {exc}")
buttons[0].button("+1 час", key=f"{key}-h1", on_click=_advance, args=(token,), kwargs={"hours": 1})
buttons[1].button("К 08:00 (приём)", key=f"{key}-08", on_click=_advance_to, args=(token, "08:00"))
buttons[2].button("К 20:00 (чек-ин)", key=f"{key}-20", on_click=_advance_to, args=(token, "20:00"))
buttons[3].button(
"Следующий день", key=f"{key}-d1", type="primary", on_click=_advance, args=(token,), kwargs={"days": 1}
)
if with_reset:
with buttons[4].popover("Сброс демо"):
st.write("Вернуть часы к реальному времени и очистить чат и прогресс сценария?")
st.button("Да, сбросить", key=f"{key}-reset", type="primary", on_click=_reset)
# ------------------------------------------------------------------------------------------------ chat
def _send(token: str, type_: str, *, text: str | None = None, button: str | None = None, label: str = "") -> None:
api = get_api()
chat = chat_for(token)
params: dict[str, Any] = {}
if type_ == "start":
code = (st.session_state.get(f"console_code:{token}") or "").strip()
params = {"code": code} if code else {}
chat.add_in("/start" + (f" {code}" if code else ""), pressed=True)
elif type_ == "button":
chat.add_in(label, pressed=True)
else:
chat.add_in(text or "")
try:
chat.add_replies(api.send_event(token, type_, text=text, button=button, params=params))
except ApiError as exc:
flash(f"Сообщение не доставлено: {exc}")
return
_drain_quiet(api, token)
def _submit_text(token: str, key: str) -> None:
text = (st.session_state.get(key) or "").strip()
if text:
_send(token, "text", text=text)
def _render_entry(entry: dict[str, Any]) -> None:
if entry.get("dir") == "in":
with st.chat_message("user", avatar="🧑"):
st.markdown(f"🔘 *{entry['text']}*" if entry.get("pressed") else entry["text"])
return
kind = entry.get("kind", "normal")
with st.chat_message("assistant", avatar="🤝"):
if kind == "crisis":
st.error(entry["text"], icon="🚨")
elif kind == "safety_plan":
with st.container(border=True):
st.markdown("**🛡️ План безопасности**")
st.markdown(entry["text"])
elif kind == "info":
st.caption(entry["text"])
else:
st.markdown(entry["text"])
def render_messages(token: str, *, height: int, key: str) -> None:
"""Transcript + the keyboard of the newest reply with buttons. Drains the outbox on every call."""
_drain_quiet(get_api(), token)
chat = chat_for(token)
box = st.container(height=height, border=True, autoscroll=True)
with box:
if not chat.entries:
st.caption("Сообщений пока нет. Нажмите «Начать» или сдвиньте часы к напоминанию.")
active = chat.last_buttons_index()
for i, entry in enumerate(chat.entries):
_render_entry(entry)
if i == active:
for r, row in enumerate(entry["buttons"]):
cols = st.columns(len(row))
for c, b in enumerate(row):
cols[c].button(
b["label"] or "…",
key=f"{key}-b{i}-{r}-{c}",
width="stretch",
on_click=_send,
args=(token, "button"),
kwargs={"button": b["data"], "label": b["label"]},
)
@st.fragment(run_every=POLL_SECONDS)
def _polled_messages(token: str, height: int, key: str) -> None:
render_messages(token, height=height, key=key)
def render_chat(token: str, *, height: int = 520, key: str = "chat") -> None:
"""Fallback patient web chat: polled transcript, text input, «Начать» with an optional connect code."""
_polled_messages(token, height, key)
input_key = f"{key}-input:{token}"
with st.form(f"{key}-form:{token}", clear_on_submit=True, border=False):
left, right = st.columns([5, 1])
left.text_input("Сообщение", key=input_key, label_visibility="collapsed", placeholder="Сообщение пациента…")
right.form_submit_button("➤", width="stretch", on_click=_submit_text, args=(token, input_key))
code = st.query_params.get("code") # connect links from the doctor point to the console with ?code=
if code and not st.session_state.get(f"console_code:{token}"):
st.session_state[f"console_code:{token}"] = code
with st.expander("Начать диалог / код подключения", expanded=bool(code)):
st.text_input("Код подключения (необязательно)", key=f"console_code:{token}", placeholder="DEMO-0001")
c1, c2 = st.columns(2)
c1.button("🚀 Начать", key=f"{key}-start", on_click=_send, args=(token, "start"), width="stretch")
c2.button(
"Очистить окно чата",
key=f"{key}-clear",
on_click=lambda: chat_for(token).clear(),
width="stretch",
)
def patient_web_url() -> str | None:
url = (os.environ.get("PATIENT_WEB_URL") or "").strip()
return url or None
# ------------------------------------------------------------------------------------------------ doctor panel
def _dismiss_banner(alert_id: int) -> None:
st.session_state.setdefault("console_alerts_dismissed", set()).add(alert_id)
def _ack(alert_id: int) -> None:
try:
get_api().ack(alert_id, doctor_id())
except ApiError as exc:
flash(f"Алерт не подтверждён: {exc}")
_dismiss_banner(alert_id)
def _fmt_ts(raw: Any) -> str:
try:
return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).strftime("%d.%m %H:%M")
except ValueError:
return ""
def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> None:
"""Compact doctor view for the projector: patients with colours and the live alerts feed."""
api = get_api()
doctor = doctor_id()
try:
alerts = [a for a in api.alerts(doctor) if isinstance(a, dict)]
except ApiError as exc:
alerts = []
st.warning(f"Алерты недоступны: {exc}")
ids = [int(a.get("id", -1)) for a in alerts]
# Alerts already present when the panel first loads are "old": the banner is only for new acute ones.
if "console_alerts_baseline" not in st.session_state:
st.session_state["console_alerts_baseline"] = max(ids, default=-1)
baseline = st.session_state["console_alerts_baseline"]
dismissed = st.session_state.get("console_alerts_dismissed", set())
fresh_acute = [
a
for a in alerts
if a.get("level") == "acute"
and not a.get("acked_at")
and baseline < int(a.get("id", -1))
and int(a.get("id", -1)) not in dismissed
]
for a in fresh_acute[:1]:
st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>"
f"<span style='font-size:1.05rem;font-weight:500'>{_escape(a.get('reason', ''))}</span></div>",
unsafe_allow_html=True,
)
c1, c2 = st.columns(2)
aid = int(a.get("id", -1))
c1.button("Взять в работу (ack)", key=f"{key}-ack{aid}", type="primary", on_click=_ack, args=(aid,))
c2.button("Скрыть", key=f"{key}-hide{aid}", on_click=_dismiss_banner, args=(aid,))
st.markdown("#### Пациенты")
try:
patients = [p for p in api.patients(doctor) if isinstance(p, dict)]
except ApiError as exc:
patients = []
st.warning(f"Список пациентов недоступен: {exc}")
lines = []
for p in patients:
name = str(p.get("display_name", "?"))
if p.get("token") == highlight_token:
name = f"**{name} ◀**"
extra = f" · {p['last_event']}" if p.get("last_event") else ""
lines.append(
f"{COLOR_EMOJI.get(p.get('status_color', ''), '⚪')} {name} · день {p.get('therapy_day', '—')}{extra}"
)
st.markdown("\n".join(f"- {line}" for line in lines) if lines else "_нет пациентов_")
st.markdown("#### Алерты")
if not alerts:
st.caption("Новых алертов нет")
for a in alerts[:8]:
done = " ✔︎" if a.get("acked_at") else ""
icon = LEVEL_EMOJI.get(a.get("level", ""), "⚪")
who = a.get("patient_name", "")
st.markdown(f"{icon} `{_fmt_ts(a.get('ts'))}` **{who}** — {a.get('reason', '')}{done}")
@st.fragment(run_every=POLL_SECONDS)
def polled_doctor_panel(highlight_token: str | None, key: str = "doc") -> None:
render_doctor_panel(highlight_token, key=key)
def _escape(value: Any) -> str:
text = str(value)
for ch, rep in (("&", "&amp;"), ("<", "&lt;"), (">", "&gt;")):
text = text.replace(ch, rep)
return text
# ------------------------------------------------------------------------------------------------ playback
def scenario_for(persona: dict[str, Any]) -> tuple[Scenario, str]:
"""Scenario of the persona: uploaded text (if any) wins over the repo file. Returns (scenario, source)."""
token = persona["token"]
uploaded = st.session_state.get(f"console_scenario_text:{token}")
if uploaded:
return parse_scenario(uploaded), "загруженный файл"
path = st.session_state.get(f"console_scenario_path:{token}") or default_scenario_path(persona)
if not path:
return Scenario(errors=["у персоны нет файла сценария"]), "—"
return load_scenario(path), path
def make_runner(persona: dict[str, Any], *, delay: float) -> PlaybackRunner:
token = persona["token"]
return PlaybackRunner(get_api(), token, chat_for(token), playback_state(token), doctor=doctor_id(), delay=delay)
def execute(logs: Any, state: PlaybackState, *, title: str) -> bool:
"""Run a playback iterator with a live log. Returns True if it finished without a failed step."""
ok = True
state.log.clear()
with st.status(title, expanded=True) as status:
try:
for entry in logs:
line = entry.render() if isinstance(entry, StepLog) else str(entry)
state.log.append(line)
status.write(line)
ok = ok and entry.status != "fail"
except Exception as exc: # last-resort guard: the page must stay alive
ok = False
state.log.append(f"❌ сбой проигрывания: {exc}")
status.write(state.log[-1])
status.update(label=f"{title}: {'готово' if ok else 'остановлено'}", state="complete" if ok else "error")
return ok
+124
View File
@@ -0,0 +1,124 @@
"""Doctor's patient list ("red zones", idea 2) and, with `?patient=<token>`, the patient card."""
from __future__ import annotations
from html import escape
import streamlit as st
from dashboard.components import helpers as h
from dashboard.components import patient_card, state, ui
NAV_ICON = ":material/groups:"
COLOR_FILTERS = {
"Все пациенты": None,
"Срочно": "red",
"Внимание": "yellow",
"Нет ответа": "grey",
"Без уведомлений": "green",
}
def _open(token: str) -> None:
st.session_state["patient_list_state"] = {
key: st.session_state[key]
for key in ("status_filter", "patient_query", "patient_details")
if key in st.session_state
}
st.query_params["patient"] = token
def _close() -> None:
st.query_params.pop("patient", None)
st.session_state.update(st.session_state.pop("patient_list_state", {}))
def _reset_filters() -> None:
st.session_state["status_filter"] = "Все пациенты"
st.session_state["patient_query"] = ""
def render_card(token: str) -> None:
st.button("Все пациенты", icon=":material/arrow_back:", type="tertiary", on_click=_close)
card = state.call(lambda: state.client().patient_card(token))
if card is None:
return
patient_card.render(card, state.today())
def render_list() -> None:
_, doctor_gen = state.doctor_names()
today = state.today()
patients = state.call(lambda: state.client().patients())
subtitle = "Всё важное о состоянии пациентов — в одном месте."
if today:
subtitle += f" · данные на {h.short_date(today)}"
head, action = st.columns([4, 1.4], vertical_alignment="bottom")
with head:
st.html(ui.page_header("Пациенты под наблюдением", subtitle, kicker="Рабочее пространство"))
with action:
if st.button("Подключить пациента", icon=":material/person_add:", type="primary", width="stretch"):
st.switch_page(state.CONNECT_PAGE)
if patients is None:
st.button("Повторить загрузку", icon=":material/refresh:")
return
items = h.sort_patients(patients)
st.html(ui.kpi_tiles(h.kpis(items)))
st.html(
f'<div class="mt-section-head"><h2>Ваши пациенты <span>{len(items)}</span></h2>'
"<span>Сначала — пациенты с более высоким риском</span></div>"
)
with st.container(key="patient-list"):
f1, f2 = st.columns([3, 1.4], vertical_alignment="bottom")
with f1:
st.session_state.setdefault("status_filter", "Все пациенты")
choice = st.segmented_control(
"Статус", list(COLOR_FILTERS), key="status_filter", label_visibility="collapsed"
)
with f2:
query = st.text_input(
"Поиск пациента", placeholder="Имя или препарат…", key="patient_query", label_visibility="collapsed"
)
detailed = st.toggle(
"Подробный список", key="patient_details", help="Назначение, последнее событие и факторы риска"
)
shown = h.filter_patients(items, COLOR_FILTERS.get(choice or "Все пациенты"), query or "")
if not items:
st.html(ui.empty_state("Пациентов пока нет", "Подключите первого пациента кнопкой вверху страницы."))
elif not shown:
st.html(ui.empty_state("Пациенты не найдены", "Измените имя, препарат или выбранный статус."))
st.button("Сбросить фильтры", on_click=_reset_filters)
else:
head_cols = st.columns([11, 1.4])
with head_cols[0]:
st.html(ui.list_header())
for p in shown:
with st.container(key=f"prow-{p.status_color}-{p.token}"):
row, btn = st.columns([11, 1.4], vertical_alignment="center")
with row:
st.html(ui.patient_row(p, detailed=detailed))
with btn:
st.button(
"Открыть",
key=f"open-{p.token}",
on_click=_open,
args=(p.token,),
help=f"Открыть карточку: {p.display_name}",
width="stretch",
)
st.html(
'<div class="mt-list-footer"><span role="status" aria-live="polite">'
f"Показано {len(shown)} из {len(items)} пациентов</span>"
f"<span>Кабинет {escape(doctor_gen)}</span></div>"
)
st.caption("Статус указан цветом и текстом. Риск прерывания терапии рассчитан по правилам; детали — в карточке.")
token = st.query_params.get("patient")
if token:
render_card(token)
else:
render_list()
@@ -0,0 +1,188 @@
"""«Подключить пациента»: after the in-person visit the doctor enters the prescription → one-time code + QR."""
from __future__ import annotations
import io
import os
from datetime import date, timedelta
from html import escape
import qrcode
import streamlit as st
from app.api.schemas import ConnectPatientIn, ConnectPatientOut
from dashboard.components import helpers as h
from dashboard.components import state, ui
NAV_ICON = ":material/person_add:"
TIME_OPTIONS = ["07:00", "08:00", "09:00", "10:00", "12:00", "14:00", "18:00", "20:00", "21:00", "22:00"]
MODULE_OPTIONS = ["veteran", "depression", "family"]
DEMO = {
"cp_name": "Андрей С.",
"cp_year": 1992,
"cp_sex": "м",
"cp_drug": "сертралин",
"cp_dose": "50 мг",
"cp_times": ["08:00"],
"cp_instr": "утром после еды",
"cp_modules": ["veteran", "depression"],
}
def qr_png(data: str) -> bytes:
img = qrcode.make(data, box_size=8, border=2)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _defaults(today: date) -> None:
ss = st.session_state
ss.setdefault("cp_name", "")
ss.setdefault("cp_year", 1990)
ss.setdefault("cp_sex", "м")
ss.setdefault("cp_snils", h.synthetic_snils())
ss.setdefault("cp_drug", "сертралин")
ss.setdefault("cp_times", ["08:00"])
ss.setdefault("cp_instr", "утром после еды")
ss.setdefault("cp_visit", today + timedelta(days=28))
ss.setdefault("cp_modules", ["veteran"])
def _fill_demo(today: date) -> None:
st.session_state.update(DEMO)
st.session_state["cp_snils"] = h.synthetic_snils()
st.session_state["cp_visit"] = today + timedelta(days=28)
def _new_snils() -> None:
st.session_state["cp_snils"] = h.synthetic_snils()
def _reset() -> None:
st.session_state.pop("cp_result", None)
for k in [*DEMO, "cp_snils", "cp_visit", "cp_consent"]:
st.session_state.pop(k, None)
def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True):
st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
"согласие и начнёт сопровождение. Код одноразовый.</div>"
)
c1, c2 = st.columns([1, 1.4], vertical_alignment="center")
with c1:
st.image(qr_png(link), width=220, caption="QR для подключения")
with c2:
st.html(f'<div class="mt-panel-title">Код подключения</div><div class="mt-code">{escape(res.code)}</div>')
st.link_button("Открыть ссылку", link, icon=":material/open_in_new:")
st.code(link, language=None)
b1, b2 = st.columns(2)
with b1:
if st.button("Карточка пациента", icon=":material/person:", width="stretch"):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": res.token})
with b2:
st.button("Подключить ещё", icon=":material/add:", on_click=_reset, width="stretch")
def main() -> None:
today = state.today() or date.today()
_defaults(today)
st.html(
ui.page_header(
"Подключить пациента",
"После очного приёма: назначение уходит в МИС (мок), пациент получает код и начинает сопровождение. "
"Только синтетические данные.",
kicker="Кабинет врача",
)
)
res: ConnectPatientOut | None = st.session_state.get("cp_result")
if res is not None:
_result(res, st.session_state.get("cp_result_name", ""))
return
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large")
with left, st.container(border=True):
st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2)
with a:
st.number_input("Год рождения", min_value=1940, max_value=today.year - 18, step=1, key="cp_year")
with b:
st.radio("Пол", ["м", "ж"], key="cp_sex", horizontal=True)
s1, s2 = st.columns([3, 1], vertical_alignment="bottom")
with s1:
st.text_input("СНИЛС (синтетический)", key="cp_snils")
with s2:
st.button("Новый", icon=":material/refresh:", on_click=_new_snils, width="stretch")
st.pills(
"Модули сопровождения",
MODULE_OPTIONS,
selection_mode="multi",
key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m],
)
with right, st.container(border=True):
st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1])
with d1:
drug = st.selectbox("Препарат (МНН)", list(h.DRUGS), key="cp_drug")
with d2:
doses = h.DRUGS[drug]
if st.session_state.get("cp_dose") not in doses:
st.session_state["cp_dose"] = doses[1] if len(doses) > 1 else doses[0]
st.selectbox("Доза", doses, key="cp_dose")
st.multiselect("Время приёма", TIME_OPTIONS, key="cp_times")
st.text_input("Указания", key="cp_instr")
st.date_input("Следующий визит", key="cp_visit", min_value=today, format="DD.MM.YYYY")
consent = st.checkbox(
"Пациент подписал информированное добровольное согласие на дистанционное наблюдение (323-ФЗ, ст. 20, 36.2)",
key="cp_consent",
)
if not st.button("Подключить и получить код", type="primary", icon=":material/qr_code_2:"):
return
ss = st.session_state
errors = []
if not ss["cp_name"].strip():
errors.append("укажите имя")
if not h.is_valid_snils(ss["cp_snils"]):
errors.append("СНИЛС не проходит контрольную сумму — нажмите «Новый»")
if not ss["cp_times"]:
errors.append("выберите время приёма")
if not ss["cp_modules"]:
errors.append("выберите хотя бы один модуль")
if not consent:
errors.append("нужно согласие пациента")
if errors:
st.error("Проверьте форму: " + "; ".join(errors))
return
body = ConnectPatientIn(
doctor_id=state.client().doctor_id,
display_name=ss["cp_name"].strip(),
birth_year=int(ss["cp_year"]),
sex="m" if ss["cp_sex"] == "м" else "f",
snils=ss["cp_snils"],
drug=ss["cp_drug"],
dose=ss["cp_dose"],
times=sorted(ss["cp_times"]),
instructions=ss["cp_instr"],
next_visit=ss["cp_visit"],
modules=ss["cp_modules"],
)
out = state.call(lambda: state.client().connect(body))
if out is not None:
ss["cp_result"] = out
ss["cp_result_name"] = body.display_name
st.rerun()
main()
@@ -0,0 +1,34 @@
"""Demo console (#17, ADR-004/005): web chat of the patient, demo clock and scenario playback.
Everything goes through the HTTP API (`API_BASE_URL`); the page keeps working when the backend serves fixtures.
"""
import sys
from pathlib import Path
import streamlit as st
# `streamlit run dashboard/app.py` already puts dashboard/ on sys.path; this keeps the page runnable on its own.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
persona = w.select_persona()
w.render_connect_hero()
w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль")
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).")
w.render_clock_bar(persona, key="console-clock")
st.divider()
chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col:
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat")
with play_col:
render_playback_panel(persona)
@@ -0,0 +1,58 @@
"""Split screen for the projector (#17): patient on the left, doctor on the right, clock on top.
Left: the patient web app in an iframe when `PATIENT_WEB_URL` is set (React app, e.g. http://localhost:5173),
otherwise the console's fallback web chat. Right: compact doctor panel polled every 2 s with a red banner
on a new acute alert.
"""
import sys
from pathlib import Path
import streamlit as st
# `streamlit run dashboard/app.py` already puts dashboard/ on sys.path; this keeps the page runnable on its own.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown(
"<style>.block-container{padding-top:1.2rem;max-width:100%} "
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
persona = w.select_persona()
w.show_flash()
token = persona["token"]
top, play = st.columns([6, 1.3], vertical_alignment="center")
with top:
w.render_clock_bar(persona, key="split-clock", with_reset=False)
with play:
scenario, _ = w.scenario_for(persona)
runner = w.make_runner(persona, delay=0.8)
nxt = runner.next_day(scenario) if scenario.days else None
clicked = st.button(
f"▶ Сценарий: день {nxt.day}" if nxt else "Сценарий проигран",
disabled=nxt is None,
width="stretch",
help="Проиграть следующий день из data/synthetic/<персона>.jsonl",
)
if clicked and nxt is not None:
w.execute(runner.play_day(scenario, nxt.day), w.playback_state(token), title=f"День {nxt.day}")
st.rerun()
left, right = st.columns([1.1, 1], gap="large")
with left:
url = w.patient_web_url()
if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}")
else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat")
with right:
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc")
+250
View File
@@ -0,0 +1,250 @@
"""Visual system of the doctor dashboard: status palette (ADR-006), text tokens, CSS and the plotly template.
Status colours are reserved for alert state (red = acute, yellow = high/moderate, green = nothing open,
grey = info only). They always ship together with an icon and a text label, never colour alone.
Chart series use a separate neutral blue so that a line is never confused with a status.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
Tone = Literal["red", "yellow", "green", "grey", "blue"]
@dataclass(frozen=True)
class ToneSpec:
solid: str # marks, stripes, dots
ink: str # text on light surface (WCAG AA on white / on the tint)
tint: str # badge / banner background
icon: str
label: str
TONES: dict[str, ToneSpec] = {
"red": ToneSpec("#A83236", "#A83236", "#FAEEEE", "🔴", "Красная зона"),
"yellow": ToneSpec("#A37524", "#805714", "#FBF3E4", "🟡", "Жёлтая зона"),
"green": ToneSpec("#527F61", "#38634B", "#EEF4EE", "🟢", "Стабильно"),
"grey": ToneSpec("#9AA0A6", "#565C64", "#F0F1F3", "⚪", "Нет ответа"),
"blue": ToneSpec("#2A78D6", "#1F5DA8", "#E8F1FC", "🔵", ""),
}
INK = "#213B38"
INK_2 = "#526660"
INK_3 = "#63736F"
SURFACE = "#FFFFFF"
CANVAS = "#F6F7F3"
LINE = "#E1E7E3"
SERIES = TONES["blue"].solid
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"
CSS = f"""
<style>
:root {{
--ink: {INK}; --ink-2: {INK_2}; --ink-3: {INK_3}; --line: {LINE}; --surface: {SURFACE}; --canvas: {CANVAS};
--red: {TONES["red"].solid}; --red-ink: {TONES["red"].ink}; --red-tint: {TONES["red"].tint};
--yellow: {TONES["yellow"].solid}; --yellow-ink: {TONES["yellow"].ink}; --yellow-tint: {TONES["yellow"].tint};
--green: {TONES["green"].solid}; --green-ink: {TONES["green"].ink}; --green-tint: {TONES["green"].tint};
--grey: {TONES["grey"].solid}; --grey-ink: {TONES["grey"].ink}; --grey-tint: {TONES["grey"].tint};
--blue: {TONES["blue"].solid}; --blue-ink: {TONES["blue"].ink}; --blue-tint: {TONES["blue"].tint};
}}
.stApp {{ background: var(--canvas); color: var(--ink); font-family: {FONT_STACK}; color-scheme: light; }}
.block-container {{ padding: 2rem 3rem 2.5rem; max-width: 1440px; }}
[data-testid="stHeader"] {{ background: var(--canvas); }}
[data-testid="stSidebar"] {{ background: var(--surface); border-right: 1px solid var(--line); }}
[data-testid="stSidebarNavLink"] {{ border-radius: 8px; color: var(--ink-2); }}
[data-testid="stSidebarNavLink"][aria-current="page"] {{ background: #edf3ee; color: #285e4e; }}
[data-testid="stSidebarNavLink"] span, [data-testid="stSidebarNav"] span {{ color: inherit; }}
[data-testid="stSidebar"] [data-testid="stMarkdownContainer"] {{ color: var(--ink-2); }}
[data-testid="stWidgetLabel"], [data-testid="stCaptionContainer"] {{ color: var(--ink-2); }}
[data-testid="stTextInputRootElement"], [data-baseweb="select"] > div {{
background: var(--surface); border-color: var(--line); color: var(--ink); border-radius: 8px; }}
[data-testid="stApp"] input, [data-testid="stApp"] textarea {{ color: var(--ink); background: var(--surface); }}
[data-testid="stBaseButton-secondary"], [data-testid="stBaseButton-tertiary"] {{
background: var(--surface); color: var(--ink); border-color: var(--line); border-radius: 8px; }}
[data-testid="stBaseButton-primary"] {{ background: #285e4e; border-color: #285e4e; color: #fff; border-radius: 8px; }}
[data-testid="stBaseButton-primary"]:hover {{ background: #204b3e; border-color: #204b3e; color: #fff; }}
[data-testid="stBaseButton-secondary"]:hover {{ background: #edf3ee; border-color: #b4c6ba; color: #285e4e; }}
button:focus-visible, a:focus-visible, input:focus-visible, [tabindex="0"]:focus-visible {{
outline: 3px solid #598d7a !important; outline-offset: 3px; }}
.mt-sidebar-note {{ margin-top:36px; border-top:1px solid var(--line); padding-top:20px; color:var(--ink-3);
font-size:.78rem; line-height:1.6; }}
.mt-sidebar-note strong {{ display:block; font-weight:550; color:var(--ink); margin-bottom:5px; }}
.mt-workspace {{ display:flex; justify-content:space-between; align-items:center; gap:16px;
border-bottom:1px solid var(--line); padding-bottom:24px; margin-bottom:20px; color:var(--ink-3); font-size:.8rem; }}
.mt-doctor {{ display:flex; gap:10px; align-items:center; }}
.mt-doctor strong {{ display:block; color:var(--ink); font-weight:550; font-size:.8rem; }}
.mt-doctor small {{ display:block; font-size:.7rem; }}
.mt-doctor-icon {{ width:34px; height:34px; background:#e7ede5; color:#285e4e; border-radius:50%;
display:grid; place-items:center; font-size:22px; }}
h1, h2, h3 {{ color: var(--ink); letter-spacing: -0.01em; }}
/* ---------- page header ---------- */
.mt-head {{ display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap;
margin: 10px 0 12px 0; }}
.mt-head > div:first-child {{ flex: 1 1 420px; min-width: 0; }}
.mt-head .mt-side {{ flex: 0 1 320px; text-align: right; }}
.mt-head h1 {{ font-size: 2.15rem; font-weight: 550; margin: 0; padding: 0; line-height: 1.2; }}
.mt-sub {{ color: var(--ink-2); font-size: .9rem; margin-top: 10px; }}
.mt-kicker {{ color: var(--ink-3); font-size: .68rem; text-transform: uppercase; letter-spacing: .13em;
margin-bottom:8px;
font-weight: 600; }}
/* ---------- overview and patient list ---------- */
.mt-kpis {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); background:var(--surface);
border:1px solid var(--line); border-radius:12px; padding:22px 0; margin:10px 0 18px; }}
.mt-kpi {{ padding:0 24px; border-right:1px solid var(--line); min-width:0; }}
.mt-kpi:last-child {{ border-right:0; }}
.mt-kpi .lbl {{ color:var(--ink-2); font-size:.78rem; font-weight:500; }}
.mt-kpi .lbl::before {{ content:""; display:inline-block; width:6px; height:6px; border-radius:50%;
background:var(--kpi-accent,var(--line)); margin-right:7px; vertical-align:2px; }}
.mt-kpi .val {{ color:var(--ink); font-size:2rem; font-weight:550; line-height:1.3; margin-top:8px;
font-variant-numeric:tabular-nums; }}
.mt-kpi .hint {{ color:var(--ink-3); font-size:.72rem; margin-top:3px; }}
.mt-section-head {{ display:flex; justify-content:space-between; align-items:center; gap:16px; margin:14px 0 4px; }}
.mt-section-head h2 {{ font-size:1.25rem; font-weight:550; margin:0; padding:0; }}
.mt-section-head h2 span {{ background:#e6ece5; font-size:.75rem; padding:3px 7px; border-radius:5px;
vertical-align:middle; margin-left:6px; }}
.mt-section-head > span {{ font-size:.72rem; color:var(--ink-3); }}
.st-key-patient-list {{ background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:20px; }}
.st-key-patient-list [data-variant="segmented_control"] {{
color:var(--ink-2); background:var(--surface); border-color:var(--line); font-size:.8rem; }}
.st-key-patient-list [data-variant="segmented_control"][data-selected] {{
background:#edf3ee; color:#285e4e; border-color:#b4c6ba; font-weight:600; }}
.mt-cols, .mt-row {{ display:grid; align-items:center; gap:18px;
grid-template-columns:minmax(0,1.8fr) minmax(0,1.6fr) minmax(0,1fr) minmax(0,.8fr) minmax(0,1.2fr); }}
.mt-cols {{ color:var(--ink-3); font-size:.63rem; letter-spacing:.05em; text-transform:uppercase;
font-weight:550; padding:12px 0 4px; }}
.mt-row {{ color:var(--ink); font-size:.82rem; }}
.mt-row > div {{ min-width:0; overflow-wrap:anywhere; }}
.mt-name {{ font-weight:600; font-size:.92rem; }}
.mt-meta {{ color:var(--ink-3); font-size:.7rem; margin-top:4px; }}
.mt-num {{ font-variant-numeric:tabular-nums; font-weight:550; }}
.mt-dim {{ color:var(--ink-3); }}
.mt-dot {{ width:6px; height:6px; border-radius:50%; display:inline-block; flex:none; }}
.mt-scale-label {{ display:inline-block; min-width:42px; font-size:.67rem; color:var(--ink-3); margin-right:6px; }}
.mt-scores > div + div {{ margin-top:4px; }}
.mt-adherence .mt-bar {{ max-width:60px; height:3px; }}
.mt-mobile-label {{ display:none; }}
.mt-row-details {{ grid-column:1 / -1; display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px;
font-size:.75rem; color:var(--ink-2); padding-top:12px; border-top:1px dashed var(--line); line-height:1.6; }}
.mt-field-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:4px; }}
div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4px; }}
.mt-list-footer {{ border-top:1px solid var(--line); padding-top:16px; margin-top:8px; display:flex;
justify-content:space-between; gap:12px; color:var(--ink-3); font-size:.72rem; }}
.mt-empty {{ text-align:center; padding:42px 20px; color:var(--ink-2); }}
.mt-empty strong {{ display:block; color:var(--ink); font-size:1rem; font-weight:550; }}
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
.mt-badge.t-yellow {{ background: var(--yellow-tint); color: var(--yellow-ink); }}
.mt-badge.t-green {{ background: var(--green-tint); color: var(--green-ink); }}
.mt-badge.t-grey {{ background: var(--grey-tint); color: var(--grey-ink); }}
.mt-badge.t-blue {{ background: var(--blue-tint); color: var(--blue-ink); }}
.mt-bar {{ height: 6px; background: var(--grey-tint); border-radius: 999px; overflow: hidden; margin-top: 5px; }}
.mt-bar > span {{ display:block; height: 100%; border-radius: 999px; }}
.mt-count {{ display:inline-flex; min-width: 26px; height: 26px; border-radius: 999px; align-items:center;
justify-content:center; font-weight: 700; font-size: .85rem; }}
.mt-tip {{ position: relative; cursor: help; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ content: attr(data-tip); position: absolute;
z-index: 50; left: 0; top: 120%;
min-width: 220px; max-width: 320px; white-space: pre-line; background: var(--ink); color: #fff;
font-size: .78rem; font-weight: 400; line-height: 1.4; padding: 8px 10px; border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,.18); }}
/* ---------- banners & panels ---------- */
.mt-banner {{ border-radius: 12px; padding: 12px 16px; display:flex; gap: 12px; align-items:flex-start;
border: 1px solid; margin-bottom: 6px; }}
.mt-banner.t-red {{ background: #fffafa; border-color: #e7cbcb; border-left-width:3px; color: var(--red-ink); }}
.mt-banner.t-grey {{ background: var(--grey-tint); border-color: var(--line); color: var(--grey-ink); }}
.mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650;
color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }}
.mt-alert.t-yellow {{ border-left-color: var(--yellow); }}
.mt-alert.t-grey {{ border-left-color: var(--grey); }}
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink);
line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
/* ---------- dose strip ---------- */
.mt-doses {{ display:grid; gap: 4px; align-items:center; }}
.mt-doses .cell {{ height: 26px; border-radius: 6px; display:flex; align-items:center; justify-content:center;
font-size: .72rem; font-weight: 700; color: #fff; }}
.mt-doses .c-taken {{ background: var(--green); }}
.mt-doses .c-missed {{ background: var(--red); }}
.mt-doses .c-skipped {{ background: var(--yellow); color: #3D2B00; }}
.mt-doses .c-pending {{ background: var(--surface); border: 1.5px dashed var(--grey); color: var(--ink-3); }}
.mt-doses .c-none {{ background: transparent; }}
.mt-doses .lab {{ color: var(--ink-3); font-size: .72rem; text-align:center; font-variant-numeric: tabular-nums; }}
.mt-legend {{ display:flex; gap: 14px; flex-wrap: wrap; color: var(--ink-2); font-size: .8rem; margin-top: 8px; }}
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }}
/* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }}
.mt-footer {{ color: var(--ink-3); font-size: .78rem; text-align: center; margin: 36px 0 8px;
border-top: 1px solid var(--line); padding-top: 12px; }}
@media (max-width: 1100px) {{
.block-container {{ padding-left:1.5rem; padding-right:1.5rem; }}
.mt-kpi {{ padding:0 16px; }}
.mt-cols, .mt-row {{ gap:12px; }}
.mt-section-head > span {{ display:none; }}
}}
@media (max-width: 760px) {{
.block-container {{ padding:1.5rem 1rem; }}
.mt-workspace {{ flex-wrap:wrap; padding-bottom:18px; margin-bottom:10px; }}
.mt-head h1 {{ font-size:1.75rem; }}
.mt-kpis {{ grid-template-columns:repeat(2,minmax(0,1fr)); padding:0; }}
.mt-kpi {{ padding:18px; }}
.mt-kpi:nth-child(2) {{ border-right:0; }}
.mt-kpi:nth-child(-n+2) {{ border-bottom:1px solid var(--line); }}
.st-key-patient-list {{ padding:16px; }}
.mt-cols {{ display:none; }}
.mt-row {{ grid-template-columns:1fr 1fr; gap:16px; }}
.mt-person {{ grid-column:1 / -1; }}
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}}
@media (prefers-reduced-motion: reduce) {{
*, *::before, *::after {{ animation:none !important; scroll-behavior:auto !important; transition:none !important; }}
}}
</style>
"""
def plotly_layout(**overrides: object) -> dict[str, object]:
"""Shared plotly layout: recessive grid, no chart junk, the same font as the page."""
layout: dict[str, object] = {
"font": {"family": FONT_STACK, "size": 12, "color": INK_2},
"paper_bgcolor": "rgba(0,0,0,0)",
"plot_bgcolor": "rgba(0,0,0,0)",
"margin": {"l": 8, "r": 8, "t": 36, "b": 8},
"hoverlabel": {"bgcolor": INK, "font": {"color": "#FFFFFF", "family": FONT_STACK}},
"showlegend": False,
"hovermode": "x unified",
}
layout.update(overrides)
return layout
AXIS = {"showgrid": True, "gridcolor": LINE, "zeroline": False, "linecolor": LINE, "tickcolor": LINE, "ticks": ""}
PLOTLY_CONFIG = {"displayModeBar": False, "responsive": True}
+92
View File
@@ -0,0 +1,92 @@
# Мок электронной медицинской карты ЕМИАС (интеграция ЕМИАС/ЕГИСЗ — адаптер, SPEC §9). ТОЛЬКО СИНТЕТИКА.
# Ключ — синтетический СНИЛС из scripts/seed_demo.py; в рантайме превращается в токен через pseudo_id(), так что
# в чат-агента не попадают ни СНИЛС, ни ФИО: только клинические поля ниже.
#
# Поля:
# diagnoses — диагнозы МКБ-10 из карты (ставит врач; агент их не озвучивает как «диагноз», только учитывает)
# conditions — сопутствующие состояния и анамнез; `tags` сверяются с data/meds/drug_safety.yaml
# allergies — аллергии и непереносимости; `tags` сверяются с составом препаратов (вспомогательные вещества)
# other_medications — назначения других специалистов; `classes` сверяются со взаимодействиями
# pregnancy — для женщин: беременность/лактация (null — нет данных)
# Текущее психотропное назначение приходит из таблицы prescriptions (мок МИС, app/integrations/mis_mock.py).
records:
"900-000-013 34": # Андрей С., 34, ветеран СВО — герой демо
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
- {code: "T93.2", name: "Последствия осколочного ранения нижней конечности"}
conditions:
- {name: "Язвенная болезнь желудка в анамнезе (2019), ремиссия", tags: [ulcer_history]}
- {name: "Хронический болевой синдром после ранения", tags: [chronic_pain]}
allergies:
- {name: "Непереносимость лактозы", tags: [lactose]}
other_medications:
- {drug: "ибупрофен", how: "по требованию при боли", prescribed_by: "хирург", classes: [nsaid]}
pregnancy: null
"900-000-041 01": # Дмитрий В., 41, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
conditions:
- {name: "Артериальная гипертензия", tags: [hypertension]}
allergies: []
other_medications:
- {drug: "амлодипин", how: "ежедневно", prescribed_by: "терапевт", classes: [antihypertensive]}
pregnancy: null
"900-000-037 02": # Сергей П., 37, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
conditions:
- {name: "Мигрень", tags: [migraine]}
allergies: []
other_medications:
- {drug: "суматриптан", how: "при приступе мигрени", prescribed_by: "невролог", classes: [triptan]}
pregnancy: null
"900-000-052 03": # Николай Ф., 52, ветеран
diagnoses:
- {code: "F43.1", name: "Посттравматическое стрессовое расстройство"}
- {code: "F10.1", name: "Пагубное употребление алкоголя"}
conditions:
- {name: "Фибрилляция предсердий", tags: [atrial_fibrillation]}
allergies: []
other_medications:
- {drug: "варфарин", how: "ежедневно под контролем МНО", prescribed_by: "кардиолог", classes: [anticoagulant]}
pregnancy: null
"900-000-023 04": # Анна Л., 23
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
conditions: []
allergies: []
other_medications: []
pregnancy: {status: "не беременна, планирует беременность в течение года"}
"900-000-034 05": # Ирина К., 34
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
conditions: []
allergies: []
other_medications: []
pregnancy: null
"900-000-029 06": # Ольга Н., 29
diagnoses:
- {code: "F32.1", name: "Депрессивный эпизод средней степени"}
- {code: "F41.1", name: "Генерализованное тревожное расстройство"}
conditions: []
allergies: []
other_medications: []
pregnancy: null
"900-000-045 07": # Павел Р., 45
diagnoses:
- {code: "F33.4", name: "Рекуррентное депрессивное расстройство, ремиссия"}
conditions:
- {name: "Эпилепсия, ремиссия 5 лет", tags: [epilepsy]}
allergies: []
other_medications:
- {drug: "вальпроевая кислота", how: "ежедневно", prescribed_by: "невролог", classes: [anticonvulsant]}
pregnancy: null
+146
View File
@@ -0,0 +1,146 @@
# Структурированный справочник безопасности препаратов для чат-агента (состав, противопоказания, взаимодействия).
# ПРОТОТИП: краткий пересказ инструкций по медицинскому применению (ГРЛС), сверить с действующей инструкцией и
# утвердить у психиатра (#8). Состав (вспомогательные вещества) зависит от производителя — здесь пример для
# формы из назначения; агент обязан это оговаривать.
#
# Теги совпадают с data/emias/records.yaml:
# condition tags (conditions[].tags) → cautions[].when_condition
# allergy tags (allergies[].tags) → composition.excipient_tags
# medicine class (other_medications.classes) → interactions[].with_class
# Детерминированная проверка — app/llm/agent/drug_safety.py; LLM только пересказывает её результат.
common_ssri: &ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены (риск серотонинового синдрома)."
cautions:
- when_condition: ulcer_history
severity: high
text: "Язвенная болезнь в анамнезе: СИОЗС повышают риск желудочно-кишечных кровотечений, особенно вместе с НПВС или антикоагулянтами."
- when_condition: epilepsy
severity: medium
text: "Эпилепсия: препарат применяют с осторожностью, при появлении судорог его отменяет врач."
- when_condition: bipolar
severity: high
text: "Биполярное расстройство: возможен переход в маниакальную фазу, нужен контроль врача."
interactions:
- with_class: nsaid
severity: high
text: "НПВС (ибупрофен, диклофенак, кеторолак и др.) вместе с СИОЗС повышают риск кровотечений, прежде всего желудочно-кишечных."
- with_class: anticoagulant
severity: high
text: "Антикоагулянты (варфарин и др.): возрастает риск кровотечений, нужен более частый контроль свёртываемости."
- with_class: triptan
severity: medium
text: "Триптаны (суматриптан и др.): описан риск серотонинового синдрома, о сочетании должен знать врач."
- with_class: serotonergic_opioid
severity: high
text: "Трамадол и другие серотонинергические опиоиды: риск серотонинового синдрома и судорог."
- with_class: maoi
severity: high
text: "Ингибиторы МАО: сочетание противопоказано."
- with_class: alcohol
severity: medium
text: "Алкоголь во время лечения не рекомендуется: ухудшает сон и настроение, усиливает сонливость и головокружение."
pregnancy: "При беременности или её планировании решение о продолжении лечения принимает врач."
drugs:
сертралин:
aliases: [sertraline, золофт, zoloft]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Одновременный приём пимозида."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "сертралина гидрохлорид"
excipients: [кальция гидрофосфат, целлюлоза микрокристаллическая, гипролоза, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80]
excipient_tags: []
note: "Пример состава оригинального препарата; у дженериков состав может отличаться (в части из них есть лактоза) — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению сертралина (пересказ для прототипа)"
пароксетин:
aliases: [paroxetine, паксил, paxil, рексетин]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Одновременный приём тиоридазина или пимозида."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "пароксетина гидрохлорида гемигидрат"
excipients: [кальция гидрофосфата дигидрат, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80]
excipient_tags: []
note: "Пример состава; у дженериков возможны отличия (в том числе лактоза) — сверить с инструкцией на упаковке."
pregnancy: "При беременности или её планировании решение принимает врач; для пароксетина в инструкции отдельно указан риск для плода в первом триместре."
source: "ГРЛС, инструкция по медицинскому применению пароксетина (пересказ для прототипа)"
эсциталопрам:
aliases: [escitalopram, ципралекс, cipralex, селектра]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 14 дней после их отмены."
- "Удлинение интервала QT (врождённое или приобретённое) и приём препаратов, удлиняющих QT."
composition:
form: "таблетки, покрытые плёночной оболочкой"
active: "эсциталопрама оксалат"
excipients: [целлюлоза микрокристаллическая, кремния диоксид коллоидный, тальк, кроскармеллоза натрия, магния стеарат, гипромеллоза, макрогол, титана диоксид]
excipient_tags: []
note: "Пример состава оригинального препарата; у дженериков состав может отличаться — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению эсциталопрама (пересказ для прототипа)"
флуоксетин:
aliases: [fluoxetine, прозак, prozac, профлузак]
class: "антидепрессант, СИОЗС"
<<: *ssri
contraindications:
- "Повышенная чувствительность к компонентам препарата."
- "Одновременный приём ингибиторов МАО и 5 недель после отмены флуоксетина перед их назначением."
- "Одновременный приём тиоридазина или пимозида."
composition:
form: "капсулы"
active: "флуоксетина гидрохлорид"
excipients: [крахмал кукурузный, диметикон, желатин (оболочка капсулы), титана диоксид, красители]
excipient_tags: [gelatin]
note: "Пример состава; у разных производителей отличается (в части капсул есть лактоза) — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению флуоксетина (пересказ для прототипа)"
ибупрофен:
aliases: [ibuprofen, нурофен, nurofen, миг]
class: "нестероидный противовоспалительный препарат (НПВС)"
classes: [nsaid]
contraindications:
- "Эрозивно-язвенные поражения желудка и кишечника в стадии обострения, желудочно-кишечное кровотечение."
- "Повышенная чувствительность к компонентам, «аспириновая» астма."
cautions:
- when_condition: ulcer_history
severity: high
text: "Язвенная болезнь в анамнезе: НПВС применяют с осторожностью из-за риска обострения и кровотечения."
interactions:
- with_class: ssri
severity: high
text: "Вместе с антидепрессантами группы СИОЗС повышается риск кровотечений."
- with_class: anticoagulant
severity: high
text: "С антикоагулянтами возрастает риск кровотечений."
composition:
form: "таблетки, покрытые оболочкой"
active: "ибупрофен"
excipients: [кроскармеллоза натрия, натрия лаурилсульфат, натрия цитрат, стеариновая кислота, кремния диоксид коллоидный, сахароза, тальк]
excipient_tags: []
note: "Состав зависит от производителя — сверить с инструкцией на упаковке."
source: "ГРЛС, инструкция по медицинскому применению ибупрофена (пересказ для прототипа)"
# Классы препаратов из назначений психиатра (для проверки «другой препарат × текущая терапия»).
drug_classes:
ssri: [сертралин, пароксетин, эсциталопрам, флуоксетин, циталопрам, флувоксамин]
# Подписи тегов непереносимостей для ответа пациенту.
allergy_labels:
lactose: "лактоза"
gelatin: "желатин"
+57
View File
@@ -0,0 +1,57 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: эсциталопрам
aliases: [escitalopram, ципралекс, cipralex, селектра]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частая нежелательная реакция — тошнота; также возможны головная боль, бессонница или сонливость,
повышенная потливость, диарея, усталость.
Побочные эффекты чаще возникают в первые 1–2 недели лечения и обычно ослабевают или проходят при продолжении
приёма.
Возможны нарушения половой функции: снижение влечения, нарушение эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Улучшение обычно отмечается через 2–4 недели регулярного приёма.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Фармакодинамического взаимодействия с алкоголем не ожидается, однако, как и с другими препаратами этой группы,
их одновременный приём не рекомендован.
Алкоголь сам по себе ухудшает сон и настроение.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу,
раздражительность, тошноту, головную боль.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение 1–2 недель или дольше,
по решению и под контролем врача.
Изменять схему лечения может только лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают один раз в сутки независимо от приёма пищи.
Таблетки принимают ежедневно примерно в одно и то же время.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО и пимозидом противопоказан.
Не рекомендуется совместное применение с препаратами, удлиняющими интервал QT.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: головокружение, дрожь, возбуждение, сонливость, тошнота, рвота, учащённое
сердцебиение, нарушения сердечного ритма.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+52
View File
@@ -0,0 +1,52 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: флуоксетин
aliases: [fluoxetine, прозак, prozac]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: тошнота, бессонница, головная боль, нервозность и беспокойство,
снижение аппетита, повышенная потливость, диарея.
Многие из них, в том числе тошнота, выражены в начале лечения и обычно уменьшаются в течение первых недель.
Возможны нарушения половой функции: снижение влечения, нарушение эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Улучшение обычно отмечается через 2–4 недели регулярного приёма, иногда позже.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Флуоксетин выводится из организма медленно, поэтому симптомы отмены могут появиться не сразу, а через
несколько дней или недель.
При прекращении приёма возможны головокружение, нарушения сна, тревога, раздражительность, тошнота.
Препарат не следует отменять самостоятельно: решение об отмене принимает лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают внутрь, обычно один раз в сутки утром, независимо от приёма пищи.
Капсулы проглатывают, запивая водой.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО, пимозидом и тиоридазином противопоказан; из-за медленного выведения
флуоксетина интервал перед назначением ингибиторов МАО составляет не менее 5 недель.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: тошнота, рвота, возбуждение, учащённое сердцебиение, судороги.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+50
View File
@@ -0,0 +1,50 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: миртазапин
aliases: [mirtazapine, ремерон, remeron, каликста]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: сонливость, повышение аппетита и массы тела, сухость во рту,
головокружение, головная боль, отёки.
Сонливость чаще выражена в первые недели лечения и обычно уменьшается при продолжении приёма.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно: первые признаки улучшения обычно появляются через 1–2 недели,
полный эффект — через 2–4 недели регулярного приёма.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Миртазапин усиливает угнетающее действие алкоголя на нервную систему, поэтому во время лечения от алкоголя
следует воздержаться.
Сочетание с алкоголем усиливает сонливость и замедляет реакцию.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма может вызвать симптомы отмены: головокружение, тревогу, возбуждение, головную боль,
тошноту.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат обычно принимают один раз в сутки вечером, перед сном, независимо от приёма пищи.
Таблетку проглатывают, не разжёвывая, запивая водой.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО противопоказан.
Миртазапин усиливает седативное действие бензодиазепинов и других успокоительных средств.
Совместное применение с другими серотонинергическими средствами повышает риск серотонинового синдрома.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: выраженная сонливость, дезориентация, учащённое сердцебиение.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+56
View File
@@ -0,0 +1,56 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: пароксетин
aliases: [paroxetine, паксил, paxil, рексетин]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: тошнота, сонливость или бессонница, повышенная потливость, дрожь,
слабость, сухость во рту, запор, снижение аппетита.
Частота и выраженность многих побочных эффектов, в том числе тошноты, уменьшаются при продолжении лечения,
обычно в первые 1–2 недели.
Возможны нарушения половой функции: снижение влечения, нарушение эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Улучшение обычно отмечается через 2–4 недели регулярного приёма, а в первые дни самочувствие может не меняться.
Оценивает эффект и решает, нужно ли менять лечение, лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Хотя пароксетин не усиливает вредное действие алкоголя на психомоторные функции, их одновременный приём
не рекомендован.
Алкоголь сам по себе ухудшает сон и настроение.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма часто вызывает синдром отмены: головокружение, нарушения сна и яркие сновидения,
тревогу, раздражительность, тошноту, потливость, ощущение покалывания.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, в течение нескольких недель, по решению
и под контролем врача.
Изменять схему лечения может только лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают один раз в сутки утром, во время завтрака.
Таблетку проглатывают целиком, не разжёвывая, запивая водой.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО, тиоридазином и пимозидом противопоказан.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
Пароксетин влияет на обмен ряда препаратов в печени, поэтому о любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: тошнота, рвота, дрожь, расширение зрачков, сухость во рту, раздражительность,
сонливость.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+49
View File
@@ -0,0 +1,49 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Празозин зарегистрирован как средство, снижающее артериальное давление; при ночных кошмарах его назначает
# врач вне зарегистрированных показаний — сроки эффекта в инструкции для этого случая не указаны.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: празозин
aliases: [prazosin, минипресс, minipress]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции: головокружение, сонливость, головная боль, слабость, сердцебиение,
тошнота.
После первого приёма возможно резкое снижение давления при вставании с головокружением и даже обмороком
(«эффект первой дозы»).
Головокружение обычно уменьшается при продолжении лечения; о стойких побочных эффектах нужно сообщить врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Фармакологические свойства»"
text: >-
Действие на артериальное давление начинается через несколько часов после приёма.
Срок, через который ожидается эффект в вашем случае, определяет лечащий врач.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Алкоголь усиливает снижение давления, головокружение и сонливость.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Препарат не следует прекращать или возобновлять самостоятельно.
После перерыва в приёме снова возможен «эффект первой дозы», поэтому порядок возобновления определяет врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Первый приём рекомендуется вечером перед сном, в положении лёжа.
Вставать с постели после приёма следует медленно, особенно ночью.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с другими средствами, снижающими давление, и с препаратами для лечения эректильной
дисфункции усиливает снижение давления.
О любых других лекарствах нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: резкое снижение давления, выраженная сонливость, слабость, обморок.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+57
View File
@@ -0,0 +1,57 @@
# Выдержки для прототипа, сверить с действующей инструкцией ГРЛС (grls.rosminzdrav.ru).
# Синтетический пересказ: тексты сокращены и упрощены для пациента, не являются официальной инструкцией.
# Правила: 1–4 предложения на раздел; схемы дозирования — только в administration (и без миллиграммов);
# без диагнозов и советов изменить лечение. Бот отвечает о препарате только из этих текстов (app/llm/med_qa.py).
inn: сертралин
aliases: [sertraline, золофт, zoloft]
sections:
side_effects:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Побочное действие»"
text: >-
Наиболее частые нежелательные реакции в начале лечения: тошнота, диарея, сухость во рту, бессонница или
сонливость, головная боль и головокружение.
Тошнота и другие нежелательные явления начала лечения обычно ослабевают или проходят в течение 1–2 недель.
Возможны нарушения половой функции: снижение влечения, задержка эякуляции.
О стойких или выраженных побочных эффектах нужно сообщить лечащему врачу.
onset:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Терапевтический эффект развивается постепенно.
Первые признаки улучшения могут появиться в течение первой недели, а полный эффект обычно достигается через
2–4 недели регулярного приёма.
В первые дни, пока эффект ещё не развился, самочувствие может не улучшаться, а побочные эффекты ощущаются
сильнее.
alcohol:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Во время лечения употребление алкоголя не рекомендуется.
Хотя в исследованиях сертралин не усиливал действие алкоголя, их одновременный приём не рекомендован.
Алкоголь сам по себе ухудшает сон и настроение и может усиливать побочные эффекты, например сонливость и
головокружение.
discontinuation:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Особые указания»"
text: >-
Резкое прекращение приёма может вызвать синдром отмены: головокружение, нарушения сна, тревогу, тошноту,
ощущение покалывания.
Препарат не следует отменять самостоятельно: отмену проводят постепенно, по решению и под контролем врача.
Изменять схему лечения может только лечащий врач.
administration:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Способ применения и дозы»"
text: >-
Препарат принимают внутрь один раз в сутки, утром или вечером, независимо от приёма пищи.
Таблетки принимают ежедневно примерно в одно и то же время.
Дозу и схему приёма определяет лечащий врач.
interactions:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Взаимодействие с другими лекарственными средствами»"
text: >-
Одновременный приём с ингибиторами МАО и пимозидом противопоказан.
Совместное применение с другими серотонинергическими средствами (трамадол, триптаны, препараты зверобоя)
повышает риск серотонинового синдрома.
С антикоагулянтами и обезболивающими из группы НПВП возрастает риск кровотечений.
О любых других лекарствах, включая безрецептурные, нужно сообщить врачу.
overdose:
source: "ГРЛС, инструкция по медицинскому применению, раздел «Передозировка»"
text: >-
Симптомы передозировки: сонливость, тошнота, рвота, учащённое сердцебиение, дрожь, возбуждение,
головокружение.
При приёме большего количества, чем назначено, нужно немедленно обратиться за медицинской помощью (103 или 112).
+165
View File
@@ -0,0 +1,165 @@
{
"_comment": "СИНТЕТИКА. Назначения-образцы для mis_mock (заглушка МИС/ЕГИСЗ, SPEC §9). Ключ — токен пациента; источник первого уровня — таблица prescriptions клинической БД, этот файл — запасной. Персоны: docs/product/01_demo_persona.md.",
"patients": [
{
"persona": "andrey",
"tokens": [
"tok-andrey"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "50 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "dmitry",
"tokens": [
"tok-dmitry"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "100 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "sergey",
"tokens": [
"tok-sergey"
],
"prescriptions": [
{
"drug": "пароксетин",
"dose": "20 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром во время завтрака",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "nikolay",
"tokens": [
"tok-nikolay"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "50 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "anna",
"tokens": [
"tok-anna"
],
"prescriptions": [
{
"drug": "флуоксетин",
"dose": "20 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "irina",
"tokens": [
"tok-irina"
],
"prescriptions": [
{
"drug": "эсциталопрам",
"dose": "10 мг",
"schedule": {
"times": [
"09:00"
]
},
"instructions": "утром",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "olga",
"tokens": [
"tok-olga"
],
"prescriptions": [
{
"drug": "сертралин",
"dose": "50 мг",
"schedule": {
"times": [
"08:00"
]
},
"instructions": "утром после еды",
"start_date": null,
"end_date": null
}
]
},
{
"persona": "pavel",
"tokens": [
"tok-pavel"
],
"prescriptions": [
{
"drug": "эсциталопрам",
"dose": "10 мг",
"schedule": {
"times": [
"09:00"
]
},
"instructions": "утром",
"start_date": null,
"end_date": null
}
]
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{"day": 0, "note": "День 0, визит к психиатру. Врач подключил Андрея (код из seed). Андрей пишет боту: согласие, знакомство, короткий скрининг (PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C), вопрос про оружие. Баллы пациенту не показываются.", "actions": [{"text": "Здравствуйте"}, {"press": "Согласен(на)"}, {"press": "Начнём"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Да"}, {"press": "Нет"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Сильно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Умеренно"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Ни разу"}, {"press": "2–4 раза в месяц"}, {"press": "3–4"}, {"press": "Никогда"}, {"press": "Нет"}, {"expect_color": "green"}]}
{"day": 1, "note": "День 1. 08:00 — напоминание о сертралине → «Принял». 20:00 — чек-ин: настроение, сон, кошмары, алкоголь.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}, {"expect_color": "green"}]}
{"day": 2, "note": "День 2. «Подташнивает» → ответ по инструкции к препарату (ГРЛС): частая побочка, проходит за 1–2 недели.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "подташнивает с утра"}, {"expect_color": "green"}]}
{"day": 3, "note": "День 3. Тошнота снова → короткая поддержка, инструкция не повторяется.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "4–6 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "опять тошнит"}, {"expect_color": "green"}]}
{"day": 4, "note": "День 4. Побочка третий день подряд → правило side_effect_3d, у врача жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "тошнота не проходит"}, {"expect_color": "yellow"}]}
{"day": 5, "note": "День 5. «Пропущу» → «Решил(а) бросить»; вечером «брошу» → отсроченный эффект, не бросать без врача; алерт quit_intent (жёлтый).", "actions": [{"advance_to": "08:00"}, {"press": "Пропущу"}, {"press": "Решил(а) бросить"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "от таблеток только хуже, брошу"}, {"expect_color": "yellow"}]}
{"day": 6, "note": "День 6. Врач позвонил Андрею. ВЕДУЩИЙ: до вечернего чек-ина подтвердите (ack) алерты Андрея на дашборде — тогда он снова зелёный.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "6–8 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"text": "врач позвонил, продолжаю"}]}
{"day": 7, "note": "День 7. 10:00 — плановые шкалы по расписанию модулей: скрининг C-SSRS (все «Нет»), затем PHQ-9 = 13 (п.9 = 0). PCL-5 по расписанию раз в 14 дней.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "10:00"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Несколько дней"}, {"press": "Ни разу"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "6–8 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}]}
{"day": 8, "note": "День 8. Сон < 4 ч, кошмары, 1–2 порции алкоголя.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "1–2 порции"}, {"text": "снится одно и то же"}]}
{"day": 9, "note": "День 9. Сон < 4 ч, алкоголь больше.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Больше"}, {"press": "Пропустить"}]}
{"day": 10, "note": "День 10. Сон < 4 ч три ночи + алкоголь три дня → информация о взаимодействии с сертралином и кнопка «Записаться к врачу раньше»; у врача жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "4"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Больше"}, {"text": "без пива не могу уснуть"}, {"press": "Записаться к врачу раньше"}, {"expect_color": "yellow"}]}
{"day": 11, "note": "День 11. Алерты открыты, правило молчит (без повторов).", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "1–2 порции"}, {"press": "Пропустить"}, {"expect_color": "yellow"}]}
{"day": 12, "note": "День 12. Настроение 3 второй день подряд, у врача по-прежнему жёлтый.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "3"}, {"press": "4–6 ч"}, {"press": "Нет"}, {"press": "Не пил(а)"}, {"press": "Пропустить"}, {"expect_color": "yellow"}]}
{"day": 13, "note": "День 13. «Всё бессмысленно» → скрининг C-SSRS кнопками; настроение ≤ 3 три дня → внеплановый PHQ-9 = 18, п.9 = 2 → кризисный скрипт, план безопасности, алерт дежурному врачу — красный.", "actions": [{"advance_to": "08:00"}, {"press": "Принял(а)"}, {"advance_to": "20:00"}, {"press": "2"}, {"press": "Меньше 4 ч"}, {"press": "Да"}, {"press": "Не пил(а)"}, {"text": "всё бессмысленно, устал от всего"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Нет"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"press": "Более половины дней"}, {"expect_color": "red"}]}
+6
View File
@@ -3,6 +3,12 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами
**Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34).
**Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача.
**Причина:** работоспособность офлайн и детерминизм важнее recall на перефразированных вопросах. Каждое показанное утверждение можно проверить по странице КР.
**Последствия:** разговорные формулировки без общих слов с КР находятся хуже — это лечится словарём `synonyms` в `config/rag/sources.yaml`. Качество поиска контролирует набор контрольных вопросов в `tests/test_llm_rag.py` (hit@3 ≥ 15/18). Гибридный поиск с эмбеддингами можно добавить за тем же интерфейсом `BM25Index.search`.
## 2026-10-03 — ADR-010: Фокус — ветераны СВО; разработчики делят слои, а не направления ## 2026-10-03 — ADR-010: Фокус — ветераны СВО; разработчики делят слои, а не направления
**Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд. **Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд.
**Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/<module>.py` + `config/modules.yaml`. **Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/<module>.py` + `config/modules.yaml`.
+29
View File
@@ -0,0 +1,29 @@
"""Сверка txt с PDF: какие слова из pdftotext не попали в txt (с учётом количества)."""
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
WORD = re.compile(r"[\wё]+", re.I)
def words(text: str) -> Counter[str]:
text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text) # переносы
return Counter(w.lower() for w in WORD.findall(text))
args = sys.argv[1:]
out = None
if "--out" in args:
i = args.index("--out")
out = Path(args[i + 1])
del args[i : i + 2]
for arg in args:
pdf = Path(arg)
ref = words(subprocess.run(["pdftotext", str(pdf), "-"], capture_output=True, text=True).stdout)
got = words(((out / pdf.name) if out else pdf).with_suffix(".txt").read_text())
lost = ref - got
total = sum(ref.values())
print(f"{pdf.name}: слов {total}, потеряно {sum(lost.values())} ({sum(lost.values()) / total:.2%})")
print(" ", lost.most_common(40))
@@ -0,0 +1,5 @@
Рисунок 1 «Схема течения ДЭ и РДР и этапов терапии» (модель D.J. Kupfer, 1991). Описание графика:
- Горизонтальная пунктирная линия делит уровни «Симптомы» (выше) и «Синдром» (ниже). Кривая состояния опускается ниже линии — «Прогрессирование заболевания» до уровня синдрома.
- «Эффективное лечение» (стрелка) — кривая поднимается обратно выше линии и выходит на плато: точка «Ремиссия».
- После ремиссии на плато отмечена точка «Обострение» (пунктирные ветви вниз — возможный возврат симптомов), далее точка «Выздоровление», затем точка «Рецидив» (пунктирная ветвь вниз).
- Подписи этапов терапии под графиком: «Купирующая» (от начала эффективного лечения до ремиссии), «Стабилизирующая» (от ремиссии до выздоровления; в этот период возможно обострение), «Противорецидивная» (после выздоровления; в этот период возможен рецидив).
@@ -0,0 +1,62 @@
Б1. Блок-схема «Этапы ведения пациента (взрослые) с ДР или РДР». Пошаговая запись. Ссылки «Стр. N, раздел X» стоят на схеме мелким шрифтом рядом с блоками и указывают на страницы исходного документа. Текст блоков сверен с официальной версией КР с рубрикатора Минздрава (КР 301, 2024 г.).
1. «Пациент с подозрением на ДЭ или РДР» → шаг 2.
2. «Диагностика пациента с подозрением на ДЭ или РДР (подсхема Б.2)» (Стр. 17, раздел 2.1–2.5) → вопрос 3.
3. «Установлен ли диагноз ДЭ или РДР?»
- Нет → «Диагностика другого психического расстройства».
- Да → шаг 4.
4. «Купирующая терапия (подсхема Б.3 или Б.4)» (Стр. 21, раздел 3.1–3.4) → вопрос 5.
5. «Достигнута ли ремиссия?»
- Нет → возврат к шагу 4 (купирующая терапия).
- Да → «Стабилизирующая терапия» (Стр. 40, раздел 3.5) → вопрос 6.
6. «Сохраняется ли ремиссия не менее 6 месяцев?»
- Нет → возврат к шагу 4 (купирующая терапия).
- Да → вопрос 7.
7. «Необходима ли противорецидивная терапия?»
- Нет → «Окончание терапии» (Стр. 44, раздел 3.6).
- Да → «Противорецидивная терапия, психообразование» (Стр. 41, раздел 3.6) → вопрос 8.
8. «Возник ли рецидив ДЭ?»
- Да → возврат к шагу 4 (купирующая терапия).
- Нет → «Окончание терапии».
Б2. Подсхема «Диагностика пациента (взрослые) с ДР или РДР» (нижняя схема на стр. 52). На этой странице схема в низком разрешении, поэтому текст блоков сверен с той же схемой в официальной версии КР с рубрикатора Минздрава (КР 301, 2024 г.). Пошаговая запись:
1. «Диагностика пациента с подозрением на ДЭ или РДР» → «Провести психиатрическое и медицинское обследование, определить клинический статус пациента» → вопрос 2.
2. «Соответствует ли состояние пациента общим критериям ДЭ?»
- Нет → «Перейти к клиническим рекомендациям, наиболее соответствующим состоянию пациента».
- Да → вопрос 3.
3. «Имеются ли сведения о маниакальном, гипоманиакальном или смешанном эпизоде в анамнезе?»
- Да → «Перейти к клиническим рекомендациям "Биполярное аффективное расстройство (F31)"».
- Нет → вопрос 4.
4. «Является ли текущий эпизод повторным?»
- Нет → «Диагностировать ДЭ (F32.-)» → вопрос 5.
- Да → «Диагностировать РДР, текущий эпизод (F33.-)» → вопрос 5.
5. «Присутствуют ли в состоянии пациента психотические симптомы?»
- Да → «Диагностировать ДЭ тяжелой степени с психотическими симптомами (F3x.3)» → вопрос 9.
- Нет → вопрос 6.
6. «Соответствует ли состояние пациента критериям ДЭ тяжелой степени без психотических симптомов?»
- Да → «Диагностировать ДЭ тяжелой степени без психотических симптомов (F3x.2)» → вопрос 10.
- Нет → вопрос 7.
7. «Соответствует ли состояние пациента критериям ДЭ средней степени?»
- Да → «Диагностировать ДЭ средней степени (F3x.1)» → вопрос 10.
- Нет → вопрос 8.
8. «Соответствует ли состояние пациента критериям ДЭ легкой степени?»
- Да → «Диагностировать ДЭ легкой степени (F3x.0)» → вопрос 10.
9. (ДЭ с психотическими симптомами) «Может ли дать и дает ли пациент согласие на госпитализацию?»
- Да → «Проводить купирующую терапию ДЭ тяжелой степени с психотическими симптомами в условиях стационара».
- Нет → «Принять решение о недобровольной госпитализации» → «Проводить купирующую терапию ДЭ тяжелой степени с психотическими симптомами в условиях стационара».
10. (ДЭ тяжелой степени без психотических симптомов, средней или легкой степени) «Существует ли суицидальный риск?»
- Да → вопрос 11.
- Нет → см. примечание ниже.
11. «Нуждается ли пациент в стационарном лечении?»
- Нет → «Проводить купирующую терапию ДЭ в амбулаторных условиях».
- Да → вопрос 12.
12. «Может ли дать и дает ли пациент согласие на госпитализацию?»
- Да → «Проводить купирующую терапию ДЭ без психотических симптомов в условиях стационара».
- Нет → вопрос 13.
13. «Есть ли показания для недобровольной госпитализации?»
- Да → «Принять решение о недобровольной госпитализации» → «Проводить купирующую терапию ДЭ без психотических симптомов в условиях стационара».
- Нет → см. примечание ниже.
Примечание: на схеме линии «Нет» от вопроса 10 («Существует ли суицидальный риск?») и от вопроса 13 («Есть ли показания для недобровольной госпитализации?») соединены друг с другом. Стрелкой ни к одному блоку они не доведены, так что куда они ведут, по схеме не определить.
@@ -0,0 +1,101 @@
Б3. Подсхема «Купирующая терапия ДЭ без психотических симптомов» (верхняя схема на стр. 53). На этой странице схема в низком разрешении, поэтому текст блоков сверен с той же схемой в официальной версии КР с рубрикатора Минздрава (cr.minzdrav.gov.ru, КР 301, 2024 г.), где она векторная. Пошаговая запись:
ЭТАП I
1. «Пациент с диагностированным ДЭ без психотических симптомов (единичный либо в рамках РДР)» → шаг 2.
2. «Назначить СИОЗС (A1), СИОЗСН (A1), другие АД нового поколения (A1), ТЦА (A1) в течение 3-4 недель. Принять решение о назначении психотерапии (A1)» → вопрос 3.
3. «Есть ли клинический эффект и хорошая переносимость?»
- Да → вопрос 4.
- Нет → «Назначить АД с другим механизмом действия (A1)» → этап II, вопрос 5.
4. «Достаточен ли клинический эффект?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → «Повысить дозу до максимальной и продолжить терапию еще 3-4 недели (A1)» → этап II, вопрос 5.
ЭТАП II
5. «Есть ли клинический эффект и хорошая переносимость?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → шаг 6.
6. «Уточнить диагноз, диагностировать соматическую патологию, коморбидные психические расстройства, органическое поражение ЦНС, неразрешенные стрессовые факторы, выявить некомплаентность» → вопрос 7. (От этого блока на схеме идёт и линия обратно к вопросу 5.)
7. «Обнаружена ли какая-либо причина?»
- Да → «Принять соответствующие меры» → возврат к шагу 6.
- Нет → этап III, шаг 8.
ЭТАП III
8. «Регистрировать относительную резистентность и перейти к любой из опций III этапа». Опции проверяются по цепочке:
8а. «Присоединен ли к АД #лития карбонат (B1), или #ламотриджин (A1), или #карбамазепин (B2)?» Да → вопрос 9; Нет → 8б.
8б. «Присоединен ли к АД арипипразол, или кветиапин**, или оланзапин**, или #рисперидон**, или #карипразин**, или #брекспипразол, или #зипрасидон, или #амисульприд (B1)?» Да → вопрос 9; Нет → 8в.
8в. «Присоединен ли к АД сульпирид** или флупентиксол** (C4)?» Да → вопрос 9; Нет → возврат к шагу 8.
9. «Есть ли клинический эффект и хорошая переносимость?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → шаг 10.
10. «Верифицировать диагноз, исключить сопутствующую патологию и лекарственные взаимодействия» → вопрос 11. (От этого блока на схеме идёт и линия обратно к вопросу 9.)
11. «Обнаружена ли какая-либо причина?»
- Да → «Принять соответствующие меры» → возврат к шагу 10.
- Нет → этап IV, шаг 12.
ЭТАП IV
12. «Перейти к любой из опций IV этапа». Опции проверяются по цепочке, каждая «Нет» ведёт к следующей:
12а. «Использованы ли другие варианты комбинированной терапии?¹»
12б. «Назначены ли другие лекарственные методы аугментации АД?²»
12в. «Назначена ли ЭСТ (B2)?»
12г. «Назначены ли другие методы электростимуляции мозга?³»
12д. «Назначена ли психотерапия (A1)?»
12е. «Назначены ли другие нелекарственные методы?⁴»
12ж. «Проведена ли одномоментная отмена терапии (C5)?» — Нет → возврат к шагу 12.
Любое «Да» → этап V, вопрос 13.
ЭТАП V
13. «Есть ли клинический эффект?»
- Да → «Продолжить терапию до достижения ремиссии».
- Нет → «Регистрировать абсолютную резистентность» → возврат к шагу 12 (опции IV этапа).
Сноски к схеме:
¹ сочетания двух АД (A1), АД с АВП (B1), с АПП (C4).
² # лития карбонат (B1), #ламотриджин (A1), АД + #карбамазепин** (B2), #буспирон (B2).
³ ТМС (A1), МКТ (C4), ТПЭСМ (B2), СБН (A2), ГСМ (B3).
⁴ светотерапия (B1), депривация сна (B2), физические упражнения (B1), рефлексотерапия (C2), РДТ (C5), ВЛОК (C5), ультрафиолетовое облучение крови (C5), нормобарическая гипоксия (C5), тренировка с биологической обратной связью (B2), плазмаферез (C5), ксенонотерапия (C5).
Б4. Подсхема «Купирующая терапия ДЭ со смешанными чертами» (средняя схема на стр. 53; текст сверен с официальной версией КР с рубрикатора Минздрава). Пошаговая запись:
1. «Диагностирован депрессивный эпизод с симптомами субсиндромальной гипомании (депрессивный эпизод со смешанными чертами)» → вопрос 2.
2. «Получает ли пациент АД?»
- Да → вопрос 3.
- Нет → ветка Б (шаг 8).
Ветка А (пациент получает АД):
3. «АД эффективен?»
- Да → вопрос 4.
- Нет → шаг 5.
4. «Усиливает ли АД симптомы гипомании?»
- Да → шаг 5.
- Нет → ИТОГ.
5. «Отменить АД (C4), назначить #зипрасидон (B2), или #луразидон (A2), или #карипразин** (B2)» → «Есть эффект?»
- Да → ИТОГ.
- Нет → шаг 6.
6. «Добавить или перевести на нормотимик (B2) или сменить АВП (#зипрасидон (B2), #луразидон (A2), кветиапин** (B2), #карипразин** (B2), оланзапин** (B2))» → «Есть эффект?»
- Да → ИТОГ.
- Нет → «ЭСТ (C4)».
Ветка Б (пациент не получает АД):
8. «Назначить #зипрасидон (B2), или #луразидон (A2), или #карипразин** (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 9.
9. «Сменить АВП первой линии или назначить оланзапин** (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 10.
10. «Добавить или перевести на нормотимик (B2)» → «Есть эффект?» Да → ИТОГ; Нет → шаг 11.
11. «Добавить АД или использовать оланзапин**+флуоксетин** (C5)» → «Есть эффект?» Да → ИТОГ; Нет → «ЭСТ (C4)».
ИТОГ: «Эффективную терапию продолжать до достижения ремиссии и переходить к этапу поддерживающей терапии (C4)».
Приложение Б5. «Алгоритм профилактики и ведения пациентов с синдромом отмены приема антидепрессантов (СОПА)» (нижняя схема на стр. 53; текст сверен с официальной версией КР с рубрикатора Минздрава). Пошаговая запись:
Боковые пометки на схеме: «Проинформировать пациента о СОПА до лечения АД» (до начала лечения) и «Повторно проинформировать пациента о СОПА при отмене АД» (на этапе отмены).
1. «Отмена АД» → вопрос 2.
2. «Пациент принимает высокие дозы АД / испытывал симптомы СОПА после пропуска приема АД / испытывал симптомы СОПА в прошлом при отмене АД / принимает АД с высоким риском развития СОПА»
- да → «Высокий риск СОПА» → «Максимально медленное снижение дозы АД» → шаг 3.
- нет → «Постепенное снижение дозы АД» → шаг 3.
3. «Симптомы СОПА» — по выраженности:
- «Легкая выраженность симптомов» → «Необходимо успокоить пациента, объяснив временный характер симптомов».
- «Умеренная выраженность симптомов» → «Симптоматическая терапия».
- «Тяжелая выраженность симптомов» → два варианта:
а) «При отмене СИОЗС или СИОЗСН можно перейти на флуоксетин; при отмене АД из других групп можно перейти на фармакодинамически сходный АД с более длительным периодом полувыведения с прекращением приема исходного АД после нескольких недель лечения новым АД и постепенной отменой последнего в последующем»;
б) «Можно возобновить АД в минимально эффективной дозе или в той дозе, при которой не было симптомов отмены, после чего отменить его более дробным и медленным титрованием».
+30
View File
@@ -0,0 +1,30 @@
Блок-схема «Алгоритм ведения пациента с посттравматическим стрессовым расстройством» (Приложение Б). Пошаговая запись:
1. Точка входа А: «Обращение к врачу общей практики, медицинскому психологу» → вопрос 2.
2. «Было в течение последних шести месяцев выраженное травматическое событие?»
- нет → «Дальнейший диагностический поиск».
- да → вопрос 3.
3. «Имеются предположительные данные за ПТСР или сомнения в оценке состояния?»
- нет → «Дальнейший диагностический поиск».
- да → «Обращение к врачу-психотерапевту, врачу-психиатру» (шаг 4).
4. Точка входа Б: «Обращение к врачу-психотерапевту, врачу-психиатру» → вопрос 5.
5. «Имеются диагностические критерии ПТСР?»
- нет → «Дальнейший диагностический поиск».
- да → «Диагноз ПТСР» → шаг 6.
6. «Оценка суицидального риска, тяжести состояния, коморбидной патологии, наличия социальной поддержки» → вопрос 7.
7. «Имеются показания для госпитализации (суицидальный риск, наличие выраженной психической или соматической патологии, отсутствие социальной поддержки)?»
- нет → «Оказание помощи в первичном звене здравоохранения (психотерапевтический кабинет, ПНД, психотерапевтический центр и др.) амбулаторно или в условиях дневного стационара» → «Проведение курса лечения ПТСР (психотерапия и фармакотерапия)» → «Оценка состояния» (шаг 10).
- да → вопрос 8.
8. «Есть актуальная соматическая патология, требующая неотложной госпитализации в соматический стационар?»
- да → «Госпитализация и лечение в соматическом стационаре» → «Оценка состояния» (шаг 10).
- нет → «Госпитализация и лечение в психиатрическом стационаре» → вопрос 9.
9. «Есть показания для помещения в психиатрическое отделение с интенсивным наблюдением (суицидальный риск, психотическая симптоматика)?»
- нет → «Госпитализация и лечение в отделении пограничных состояний (отделение неврозов)» → «Оценка состояния» (шаг 10).
- да → «Госпитализация и лечение в отделении интенсивного наблюдения» → «Оценка состояния» (шаг 10).
10. «Оценка состояния» → вопрос 11.
11. «Есть необходимость продолжения лечения?»
- да → возврат к шагу 6 («Оценка суицидального риска, тяжести состояния, коморбидной патологии, наличия социальной поддержки»).
- нет → вопрос 12.
12. «Есть необходимость проведения реабилитационных мероприятий?»
- нет → «Завершение лечения».
- да → «Направление на реабилитацию».
+18
View File
@@ -0,0 +1,18 @@
Блок-схема «Алгоритм терапии ПТСР» (Приложение Б1). Пошаговая запись. Цифры после названий (1, 2,3,4,5,6, 8, 9, 10, 11) стоят на схеме как сноски; их расшифровки на странице нет. Написание «велафаксином» сохранено как в оригинале (имеется в виду венлафаксин).
1. «ДИАГНОЗ ПТСР» → вопрос 2.
2. «Психотерапевтическое лечение возможно и целесообразно? 1»
- да → «Проведение психотерапии» → вопрос 3.
- нет → «Монотерапия сертралином/пароксетином/флуоксетином/велафаксином 2,3,4,5,6» → вопрос 3.
3. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «5,6» → «Смена антидепрессанта на другой из 1-й линии / Совместное применение психотерапии и психофармакотерапии 8» → вопрос 4.
4. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «Присоединение к терапии рисперидона, кветиапина, оланзапина 9» → вопрос 5.
5. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «Замена антидепрессанта на амитриптилин, имипрамин, миртазапин 10» → вопрос 6.
6. «Лечение эффективно?»
- да → «Поддерживающее лечение, реабилитационные мероприятия».
- нет → «Переход к стратегиям преодоления резистентности 11».
Binary file not shown.
Binary file not shown.
+184
View File
@@ -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 мг.
Binary file not shown.
+280
View File
@@ -0,0 +1,280 @@
"""PDF -> TXT для RAG.
Запуск из корня репо (нужен poppler-utils для pdftotext):
uv run --with pdfplumber python docs/source/rag/pdf_to_txt.py docs/source/rag/*.pdf --out config/rag/corpus
Сверка результата с PDF:
python docs/source/rag/check_txt.py docs/source/rag/*.pdf --out config/rag/corpus
- текст вне таблиц, строки склеены в абзацы (в том числе через границу страницы);
- таблицы -> markdown; у таблицы-продолжения на новой странице повторяется шапка,
строка, разорванная границей страницы, склеивается;
- колонтитулы (повторяющиеся строки вверху/внизу страниц) вырезаются;
- маркеры страниц `--- стр. N ---` для ссылок на источник; абзац, перетёкший на новую
страницу, склеивается, а место перехода помечается `⟦стр. N⟧`;
- текст с картинок (блок-схемы и т.п.) берётся из figures/<имя pdf>.p<N>.txt
(ручная транскрипция) и вставляется в конец своей страницы;
- если есть manual/<имя pdf>.txt, берётся он (документы, которые автоматом
не разбираются, например вёрстка Chromium с выносками).
"""
from __future__ import annotations
import math
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
from typing import Any
import pdfplumber
FIGURES_DIR = Path(__file__).parent / "figures"
MANUAL_DIR = Path(__file__).parent / "manual" # вручную выверенные txt: берутся как есть
EDGE_LINES = 4 # сколько строк сверху/снизу страницы проверять на колонтитул
SENTENCE_END = tuple(".:;!?»)")
GRADE_TAIL = re.compile(r"^([ABCАВС]|\(уровень\b.*)$") # "Уровень убедительности рекомендаций\nA\n(уровень ...)"
BULLET = re.compile(r"^([●•▪◦\-–—*]|\d+[.)]|[а-яa-z][.)]|[IVXА-Я]{1,4}[.)])\s")
HYPHEN = "\x00" # временная метка переноса "сло-\nво", снимается в resolve_hyphens
SOFT = "\x01" # временная метка разрыва строки внутри слова в узкой ячейке: "Соверше\nнно"
DEFINITION = re.compile(r"^\S+ [–—] ") # "рТМС – ..." в списке сокращений
ITEM = re.compile(r"^(\d+\s*[.)\-–]|[А-ЯA-Z]$)") # "1. СНИЖЕННОЕ НАСТРОЕНИЕ", "И" — пункт, не шапка
OPTION = re.compile(r"^0 □") # варианты ответа пункта шкалы: "0 □ Отсутствует 1 □ ..."
def norm_edge(line: str) -> str:
return re.sub(r"\d+", "#", line.strip())
def strip_junk(line: str, junk: set[str]) -> str:
"""Убирает колонтитул: целую строку, удлинённый вариант (на альбомных
страницах он шире) и прилипший к концу строки ("...на 7Страница 24 из 117")."""
n = norm_edge(line)
if n in junk or any(len(j) >= 30 and (n.startswith(j) or j.startswith(n) and len(n) >= 30) for j in junk):
return ""
for j in junk:
if len(j) >= 10 and "#" in j:
line = re.sub(re.escape(j).replace("\\#", r"\d+") + r"\s*$", "", line)
return line
def clean_cell(cell: str | None) -> str:
if not cell:
return ""
cell = re.sub(r"(\w)-\s*\n\s*(?=[а-яёa-z])", r"\1" + HYPHEN, cell) # перенос в узкой ячейке
cell = re.sub(r"([а-яё])\n(?=[а-яё])", r"\1" + SOFT, cell)
return re.sub(r"\s*\n\s*", " ", cell).replace("|", "/").strip()
def is_header(row: list[str]) -> bool:
"""Шапка — короткие подписи колонок; у таблиц-опросников её нет и первая
строка — уже пункт шкалы (его нельзя повторять на страницах-продолжениях)."""
return not ITEM.match(row[0]) and all(len(c) <= 100 for c in row)
def merge_split_rows(rows: list[list[str]]) -> list[list[str]]:
"""Пункт шкалы, разрезанный на "N. НАЗВАНИЕ" и строку с вариантами "0 □ ...",
склеивается обратно в одну строку."""
out: list[list[str]] = []
for row in rows:
if out and OPTION.match(row[0]) and "□" not in out[-1][0]:
out[-1] = [f"{a} {c}".strip() for a, c in zip(out[-1], row)]
else:
out.append(row)
return out
def table_to_md(header: list[str], body: list[list[str]]) -> str:
out = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)]
out += ["| " + " | ".join(r) + " |" for r in body]
return "\n".join(out)
def reflow(lines: list[str]) -> str:
"""Склеивает строки, разорванные вёрсткой, обратно в абзацы."""
paras: list[str] = []
for raw in lines:
line = raw.strip()
if not line:
continue
prev = paras[-1] if paras else ""
if prev and GRADE_TAIL.match(line):
paras[-1] = prev + " " + line
continue
joinable = (
prev
and line[0].islower()
and not BULLET.match(line)
and not DEFINITION.match(line)
)
if joinable and prev.endswith("-") and not prev.endswith(" -"):
paras[-1] = prev[:-1] + HYPHEN + line # перенос или составное слово
elif joinable:
paras[-1] = prev + " " + line
else:
paras.append(line)
return "\n".join(paras)
def region_text(pdf_path: Path, num: int, page: Any, top: float, bottom: float) -> str:
"""Текст горизонтальной полосы страницы. pdftotext надёжнее pdfplumber на
буквах с диакритикой (у pdfplumber выходит "K o z a r i ć")."""
y = math.floor(top)
h = math.ceil(bottom) - y
if h <= 0:
return ""
cmd = ["pdftotext", "-nopgbrk", "-f", str(num), "-l", str(num), "-x", "0", "-y", str(y),
"-W", str(math.ceil(page.width)), "-H", str(h), str(pdf_path), "-"]
return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
def word_bag(text: str) -> Counter[str]:
return Counter(w.lower() for w in re.findall(r"\w+", text.replace(HYPHEN, "")))
def page_blocks(pdf_path: Path, num: int, page: Any) -> list[tuple[str, Any]]:
"""Текстовые полосы и таблицы страницы сверху вниз."""
# у таблицы, разорванной страницей, нет нижней (или верхней) рамки — без неё
# pdfplumber теряет крайнюю строку; дорисовываем линии по концам вертикалей
ends = {round(y) for e in page.vertical_edges if e["height"] > 5 for y in (e["top"], e["bottom"])}
settings = {"explicit_horizontal_lines": sorted(ends)}
tables = []
for t in sorted(page.find_tables(settings), key=lambda t: t.bbox[1]):
rows = [[clean_cell(c) for c in row] for row in t.extract()]
# рамка, внутри которой текст лежит не в ячейках, — не таблица: иначе текст пропадёт
band = word_bag(region_text(pdf_path, num, page, t.bbox[1], t.bbox[3]))
if band - word_bag(" ".join(" ".join(r) for r in rows)):
continue
tables.append((t.bbox[1], t.bbox[3], rows))
blocks: list[tuple[str, Any]] = []
top = 0.0
for t_top, t_bottom, rows in tables:
if t_top < top: # вложенная/перекрывающаяся таблица
continue
blocks.append(("text", region_text(pdf_path, num, page, top, t_top)))
blocks.append(("table", rows))
top = t_bottom
blocks.append(("text", region_text(pdf_path, num, page, top, page.height)))
return blocks
def resolve_hyphens(text: str) -> str:
""""клинико-\nпсихопатологические" — составное слово, "антихо-\nлинерги-\nческие" —
перенос. Склеиваем без дефиса, если склейка встречается в документе целиком или
хотя бы одна половинка — не слово (обрывок слога); иначе оставляем дефис."""
vocab = {w.lower() for w in re.findall(r"\w+", re.sub(rf"\w*[{HYPHEN}{SOFT}]\w*", " ", text))}
def fix(m: re.Match[str]) -> str:
a, b = m.group(1), m.group(2)
whole = (a + b).lower() in vocab
compound = a.lower() in vocab and b.lower() in vocab
return a + b if whole or not compound else f"{a}-{b}"
pattern = re.compile(rf"(\w+){HYPHEN}(\w+)")
while HYPHEN in text:
new = pattern.sub(fix, text, count=0)
if new == text:
break
text = new
text = text.replace(HYPHEN, "-")
def fix_soft(m: re.Match[str]) -> str:
a, b = m.group(1), m.group(2)
broken = (a + b).lower() in vocab and not (a.lower() in vocab and b.lower() in vocab)
return a + b if broken else f"{a} {b}"
return re.sub(rf"(\w+){SOFT}(\w+)", fix_soft, text).replace(SOFT, " ")
def convert(pdf_path: Path) -> str:
manual = MANUAL_DIR / f"{pdf_path.stem}.txt"
if manual.exists():
return manual.read_text()
with pdfplumber.open(pdf_path) as pdf:
pages = [page_blocks(pdf_path, n, p) for n, p in enumerate(pdf.pages, 1)]
# колонтитулы: строки у края страницы, повторяющиеся на >= 40% страниц
edge: Counter[str] = Counter()
for page in pages:
lines = [l for kind, b in page if kind == "text" for l in b.splitlines() if l.strip()]
edge.update({norm_edge(l) for l in lines[:EDGE_LINES] + lines[-EDGE_LINES:]})
junk = {k for k, n in edge.items() if n >= max(3, 0.4 * len(pages))}
# blocks: ["page", N] | ["text", str] | ["table", (header, body)] | ["figure", str]
blocks: list[list[Any]] = []
for num, page in enumerate(pages, 1):
blocks.append(["page", num])
first_on_page = True
for kind, b in page:
prev = next((x for x in reversed(blocks) if x[0] != "page"), None)
if kind == "text":
text = reflow([strip_junk(l, junk) for l in b.splitlines()])
if not text:
continue
if first_on_page and prev and prev[0] == "text" and text[0].islower():
# абзац перетёк на новую страницу: дописываем его хвост к началу, а место
# перехода помечаем ⟦стр. N⟧ — чанкер по метке ставит верную страницу в цитату
head, _, rest = text.partition("\n")
prev[1] = prev[1] + f" ⟦стр. {num}⟧ " + head
if rest:
blocks.append(["text", rest])
else:
blocks.append(["text", text])
else:
rows = [r for r in b if any(r)]
if not rows:
continue
continues = (
first_on_page
and prev is not None
and prev[0] == "table"
and len(prev[1][0]) == len(rows[0])
and rows[0] != prev[1][0]
)
if continues:
header, body = prev[1]
tail = body if body else [header]
if not rows[0][0] or rows[0][0][:1].islower() or (
OPTION.match(rows[0][0]) and "□" not in tail[-1][0]
):
# строка таблицы разорвана границей страницы
tail[-1] = [f"{a} {c}".strip() for a, c in zip(tail[-1], rows[0])]
if not body:
prev[1] = (tail[0], body)
rows = rows[1:]
if rows and is_header(header):
blocks.append(["table", (header, rows)])
elif rows: # шапки нет — не дублируем первый пункт
blocks.append(["table", (rows[0], rows[1:])])
else:
blocks.append(["table", (rows[0], rows[1:])])
first_on_page = False
fig = FIGURES_DIR / f"{pdf_path.stem}.p{num}.txt"
if fig.exists():
blocks.append(["figure", fig.read_text().strip()])
out = [f"# {pdf_path.stem}", f"Источник: {pdf_path.name}, страниц: {len(pages)}"]
for kind, b in blocks:
if kind == "page":
out.append(f"--- стр. {b} ---")
elif kind == "text":
out.append(b)
elif kind == "table":
header, *body = merge_split_rows([b[0], *b[1]])
out.append(table_to_md(header, body))
else:
out.append("[Изображение: текст перенесён вручную]\n" + b)
return resolve_hyphens("\n\n".join(out)) + "\n"
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("pdfs", nargs="+", type=Path)
parser.add_argument("--out", type=Path, help="папка для txt (по умолчанию рядом с pdf)")
args = parser.parse_args()
for src in args.pdfs:
dst = (args.out / src.name if args.out else src).with_suffix(".txt")
dst.write_text(convert(src))
print(f"{src.name} -> {dst}")
+1056
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
"""API #12: dialog endpoints (events, outbox, history) and demo controls (clock, reset, personas)."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
import httpx
import pytest
from app.api import demo
from app.config import Settings
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Reply
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
DAY0 = datetime(2026, 10, 5, 9, 0)
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_identity_session() as ids:
await identity_repo.create_patient(
ids,
token=TOKEN,
display_name="Андрей С.",
snils="11122233344",
phone="+79990000001",
clinic_id="c",
doctor_id="doc-smirnova",
now=clock.now(),
birth_year=1992,
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran"],
therapy_start=clock.today() - timedelta(days=13),
)
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def test_events_reach_engine_and_unknown_patient_is_404(client: httpx.AsyncClient) -> None:
r = await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "Привет"})
assert r.status_code == 200 and r.json()["replies"]
r = await client.post("/api/v1/dialog/" + "f" * 32 + "/events", json={"type": "text", "text": "x"})
assert r.status_code == 404
async def test_outbox_drains_once(client: httpx.AsyncClient) -> None:
await integrations.outbox.push(TOKEN, [Reply(text="Пора принять лекарство")])
first = (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]
assert [x["text"] for x in first] == ["Пора принять лекарство"]
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_outbox_found_behind_wrapping_outbox(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
inner = MemoryOutbox()
class Wrapper: # like TelegramOutbox: unbound chats fall back to the console queue
fallback = inner
async def push(self, token: str, replies: list[Reply]) -> None:
await inner.push(token, replies)
monkeypatch.setattr(integrations, "outbox", Wrapper())
await inner.push(TOKEN, [Reply(text="x")])
assert len((await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]) == 1
async def test_history_oldest_first_with_limit(client: httpx.AsyncClient) -> None:
async with session.get_clinical_session() as s:
for i in range(5):
await repo.add_message(
s,
token=TOKEN,
direction="in" if i % 2 == 0 else "out",
text=ScrubbedText(f"m{i}"),
ts=clock.now() + timedelta(minutes=i),
)
rows = (await client.get(f"/api/v1/dialog/{TOKEN}/history", params={"limit": 3})).json()
assert [m["text"] for m in rows] == ["m2", "m3", "m4"]
assert [m["direction"] for m in rows] == ["in", "out", "in"]
async def test_history_stores_scrubbed_text(client: httpx.AsyncClient) -> None:
await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "мой телефон +7 999 123-45-67"})
texts = " ".join(m["text"] for m in (await client.get(f"/api/v1/dialog/{TOKEN}/history")).json())
assert "123-45-67" not in texts and "999" not in texts
async def test_clock_advance_and_reset(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(demo, "SEED_MODULE", "scripts.no_such_seed_module")
before = (await client.get("/api/v1/demo/clock")).json()
r = (await client.post("/api/v1/demo/clock/advance", json={"hours": 2})).json()
assert r["virtual"] is True and r["offset_days"] == pytest.approx(before["offset_days"] + 2 / 24, abs=1e-3)
await integrations.outbox.push(TOKEN, [Reply(text="stale")])
assert (await client.post("/api/v1/demo/reset")).json() == {"status": "ok", "seed": "skipped"}
assert (await client.get("/api/v1/demo/clock")).json()["offset_days"] == pytest.approx(0, abs=1e-6)
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_reset_calls_seed_module(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
import sys
import types
calls: list[str] = []
fake = types.ModuleType("fake_seed_demo")
async def reset() -> None:
calls.append("reset")
fake.reset = reset # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "fake_seed_demo", fake)
monkeypatch.setattr(demo, "SEED_MODULE", "fake_seed_demo")
monkeypatch.setattr(demo.util, "find_spec", lambda name: object())
assert (await client.post("/api/v1/demo/reset")).json()["seed"] == "reset"
assert calls == ["reset"]
async def test_personas_from_scenario_files(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(demo, "get_settings", lambda: Settings(_env_file=None, data_dir=tmp_path))
assert (await client.get("/api/v1/demo/personas")).json() == []
(tmp_path / "synthetic").mkdir()
(tmp_path / "synthetic" / "andrey.jsonl").write_text('{"day": 0, "actions": []}\n', encoding="utf-8")
personas = (await client.get("/api/v1/demo/personas")).json()
assert personas == [
{"token": TOKEN, "display_name": "Андрей С.", "therapy_day": 13, "scenario": "data/synthetic/andrey.jsonl"}
]
def test_persona_slug() -> None:
assert demo.persona_slug("Андрей С.") == "andrey"
assert demo.persona_slug("Юлия Щ.") == "yuliya"
assert demo.persona_slug("") == ""
+288
View File
@@ -0,0 +1,288 @@
"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from sqlalchemy import select
from app.api import doctor_views
from app.config import Settings
from app.core import clock
from app.core.privacy import pseudo_id
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.bootstrap import init_db
from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
DOCTOR = "doc-smirnova"
OTHER = "doc-other"
DAY0 = datetime(2026, 10, 5, 9, 0)
# Synthetic patients: token suffix → (name, snils, phone, doctor)
ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef")
PEOPLE = {
ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR),
DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR),
OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR),
PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR),
IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR),
STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER),
}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0 + timedelta(days=13))
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await seed()
# No lifespan: the scheduler and the Telegram bot are not started.
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def seed() -> None:
now = clock.now()
start = (DAY0).date()
async with session.get_identity_session() as ids:
for token, (name, snils, phone, doctor) in PEOPLE.items():
await identity_repo.create_patient(
ids,
token=token,
display_name=name,
snils=snils,
phone=phone,
clinic_id="clinic-1",
doctor_id=doctor,
now=now,
birth_year=1990,
sex="m",
)
async with session.get_clinical_session() as s:
for token, (_, _, _, doctor) in PEOPLE.items():
await repo.upsert_profile(
s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m"
)
s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await s.flush()
rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()}
# Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan.
await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now)
await repo.add_scale_result(
s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7)
)
await repo.add_scale_result(
s,
token=ANDREY,
scale="phq9",
total=18,
severity="mod_severe",
complete=True,
ts=now - timedelta(hours=2),
scheduled=False,
item9=2,
)
await repo.add_scale_result(
s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13)
)
for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]):
await repo.upsert_checkin(
s,
token=ANDREY,
day=now.date() - timedelta(days=2 - i),
now=now,
mood=mood,
sleep_bucket=sleep,
alcohol_bucket=alcohol,
med_taken=True,
)
s.add(
SafetyPlan(
token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now
)
)
# Dmitry: high alert (yellow). Olga: info only (grey).
await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now)
await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now)
# Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens.
for d in range(7):
planned = now - timedelta(days=d, hours=1)
s.add(
Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken")
)
s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken"))
await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5)
# Foreign doctor's patient with an open alert: must never show up for DOCTOR.
await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now)
await repo.set_dialog_state(
s,
token=ANDREY,
state="crisis",
data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}},
now=now,
)
async def audit_rows(action: str) -> list[AuditLog]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all())
async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None:
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA]
assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"]
andrey, pavel, irina = rows[0], rows[3], rows[4]
assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36
assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1
assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up")
assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13
assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale
assert rows[1]["last_event"] == "PCL-5 ≥ 50"
factors = " ".join(andrey["discontinuation_risk"]["factors"])
assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors
assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01)
assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0
assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent
assert pavel["discontinuation_risk"]["level"] == "medium"
assert len(await audit_rows("view_patient_list")) == 1
async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})
assert r.status_code == 200
card = r.json()
metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]}
assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics
assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics
assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics
assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"]
assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red"
assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]}
assert card["summary"]
rows = await audit_rows("view_patient")
assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY
async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER})
assert r.status_code == 403
assert await audit_rows("view_patient") == []
assert len(await audit_rows("view_patient_denied")) == 1
assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404
async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json()
assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient
assert all(a["patient_name"] for a in alerts)
top = max(a["id"] for a in alerts)
assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == []
assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure
async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json()
acute = next(a for a in alerts if a["token"] == ANDREY)
assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403
r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR})
assert r.status_code == 200
out = r.json()
assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С."
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, ANDREY)
profile = await s.get(PatientProfile, ANDREY)
assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False
assert profile is not None and profile.status_color == "green"
assert isinstance(integrations.outbox, MemoryOutbox)
assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console
assert len(await audit_rows("ack_alert")) == 1
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green"
async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None:
body = {
"doctor_id": DOCTOR,
"display_name": "Сергей Т.",
"birth_year": 1988,
"sex": "m",
"snils": "123-456-789 01",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00", "20:00"],
"instructions": "после еды",
"modules": ["veteran", "depression"],
}
r = await client.post("/api/v1/patients/connect", json=body)
assert r.status_code == 200
out = r.json()
assert out["token"] == pseudo_id("12345678901")
assert out["deep_link"] == f"/console?code={out['code']}"
assert "12345678901" not in r.text and "123-456-789" not in r.text
async with session.get_identity_session() as ids:
assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, out["token"])
rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"]))
assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR
assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"]
assert len(await audit_rows("connect_patient")) == 1
card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json()
assert card["prescriptions"][0]["instructions"] == "после еды"
assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green"
assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409
async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None:
texts = [
(await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text,
(await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text,
(await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text,
]
for _, snils, phone, _ in PEOPLE.values():
for text in texts:
assert snils not in text and phone not in text and phone[1:] not in text
def test_risk_index_all_factors_capped() -> None:
now = datetime(2026, 10, 18, 12, 0).astimezone()
days = [now.date() - timedelta(days=i) for i in range(4)]
checkins = [
SimpleNamespace(
day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more"
)
for d in days
]
data = doctor_views.PatientData(
profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type]
checkins=checkins, # type: ignore[arg-type]
results=[],
doses=[],
prescriptions=[],
alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item]
)
risk = doctor_views.discontinuation_risk(data, now)
assert risk.score == 100 and risk.level == "high"
assert risk.factors == [
"пропуски приёма 4/7 дн.",
"побочные эффекты 4 дн. подряд",
"хотел бросить (день 13)",
"не разобран сигнал «хочет бросить»",
"настроение ≤ 3 4 дн. подряд",
"алкоголь 4 дн. из 7",
]
+5 -6
View File
@@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None:
await session.configure() await session.configure()
def test_api_fixture_routes() -> None: def test_api_fixtures_and_clock() -> None:
# Routes read the DB since #12 (covered by tests/test_api_*.py); fixtures stay as schema-valid reference data.
from app.api import fixtures
assert fixtures.card("tok-andrey") is not None and fixtures.PATIENTS[0].status_color == "red"
with TestClient(app) as c: with TestClient(app) as c:
patients = c.get("/api/v1/doctors/doc-smirnova/patients").json()
assert patients[0]["status_color"] == "red"
card = c.get("/api/v1/patients/tok-andrey", params={"doctor_id": "doc-smirnova"}).json()
assert card["safety_plan"] and card["series"]
assert c.post("/api/v1/dialog/tok-andrey/events", json={"type": "text", "text": "ok"}).status_code == 200
assert c.get("/api/v1/demo/clock").json()["virtual"] is True assert c.get("/api/v1/demo/clock").json()["virtual"] is True
+126
View File
@@ -0,0 +1,126 @@
"""Dashboard HTTP client: paths, parsing via the frozen schemas, graceful errors (no tracebacks to the UI)."""
import json
import httpx
import pytest
from app.api import fixtures
from app.api.schemas import ConnectPatientIn
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
def _client(handler) -> ApiClient:
return ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(handler))
def _json(models) -> list | dict:
if isinstance(models, list):
return [m.model_dump(mode="json") for m in models]
return models.model_dump(mode="json")
def test_base_url_gets_api_prefix_once():
assert ApiClient("http://x:8000/").base_url == "http://x:8000/api/v1"
assert ApiClient("http://x:8000/api/v1").base_url == "http://x:8000/api/v1"
def test_doctor_id_from_env(monkeypatch):
monkeypatch.setenv("DOCTOR_ID", "doc-other")
assert ApiClient("http://x").doctor_id == "doc-other"
monkeypatch.delenv("DOCTOR_ID")
assert ApiClient("http://x").doctor_id == "doc-smirnova"
def test_patients_and_card_are_parsed():
seen: list[str] = []
def handler(req: httpx.Request) -> httpx.Response:
seen.append(str(req.url))
if req.url.path.endswith("/patients"):
return httpx.Response(200, json=_json(fixtures.PATIENTS))
return httpx.Response(200, json=_json(fixtures.andrey_card()))
c = _client(handler)
patients = c.patients()
assert patients[0].display_name == "Андрей С."
card = c.patient_card("tok-andrey")
assert card.patient.status_color == "red"
assert seen[0] == "http://api.test/api/v1/doctors/doc-smirnova/patients"
assert seen[1] == "http://api.test/api/v1/patients/tok-andrey?doctor_id=doc-smirnova"
def test_alerts_params_and_ack_body():
captured: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
if req.method == "POST":
captured["body"] = json.loads(req.content)
return httpx.Response(200, json=_json(fixtures.all_alerts()[0]))
captured["params"] = dict(req.url.params)
return httpx.Response(200, json=_json(fixtures.all_alerts()))
c = _client(handler)
assert len(c.alerts(since_id=2, open_only=True)) == 4
assert captured["params"] == {"since_id": "2", "open_only": "true"}
c.ack(3)
assert captured["body"] == {"doctor_id": "doc-smirnova"}
def test_connect_posts_payload():
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
assert body["drug"] == "сертралин" and body["times"] == ["08:00"]
return httpx.Response(200, json={"token": "t", "code": "DEMO-1", "deep_link": "https://t.me/x?start=DEMO-1"})
body = ConnectPatientIn(
doctor_id="doc-smirnova",
display_name="Андрей С.",
birth_year=1992,
sex="m",
snils="000-000-000 00",
drug="сертралин",
dose="50 мг",
)
assert _client(handler).connect(body).code == "DEMO-1"
def test_connection_error_becomes_unavailable():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=req)
with pytest.raises(ApiUnavailable) as exc:
_client(handler).patients()
assert "API недоступен" in exc.value.message
def test_timeout_becomes_unavailable():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=req)
with pytest.raises(ApiUnavailable):
_client(handler).alerts()
@pytest.mark.parametrize(("status", "fragment"), [(404, "Не найдено"), (500, "500"), (422, "422")])
def test_http_errors(status, fragment):
c = _client(lambda req: httpx.Response(status, json={"detail": "x"}))
with pytest.raises(ApiError) as exc:
c.patient_card("tok-x")
assert fragment in exc.value.message
assert exc.value.status == status
assert not isinstance(exc.value, ApiUnavailable)
def test_bad_payload_is_api_error():
c = _client(lambda req: httpx.Response(200, json=[{"token": "only"}]))
with pytest.raises(ApiError, match="неожиданный"):
c.patients()
c = _client(lambda req: httpx.Response(200, content=b"<html>"))
with pytest.raises(ApiError):
c.patients()
def test_clock_failure_is_none():
c = _client(lambda req: httpx.Response(503))
assert c.clock() is None
+419
View File
@@ -0,0 +1,419 @@
"""Demo console (#17): scenario parser, press resolver, clock math, playback runner, API client."""
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
import pytest
# Streamlit runs pages with `dashboard/` on sys.path and they import `console`; tests mirror that.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dashboard"))
from console.api import ApiError, ConsoleApi, personas_or_fallback # noqa: E402
from console.chatlog import ChatLog, normalize_reply # noqa: E402
from console.playback import ( # noqa: E402
PlaybackRunner,
PlaybackState,
hours_until,
parse_clock_now,
planned_date,
resolve_press,
target_datetime,
)
from console.scenario import ( # noqa: E402
Action,
default_scenario_path,
load_scenario,
parse_action,
parse_scenario,
persona_slug,
)
MSK = timezone(timedelta(hours=3))
def _jsonl(*rows: Any) -> str:
return "\n".join(r if isinstance(r, str) else json.dumps(r, ensure_ascii=False) for r in rows)
def _reply(text: str, *labels_data: tuple[str, str], kind: str = "normal") -> dict[str, Any]:
return {"text": text, "kind": kind, "buttons": [[{"label": lab, "data": d} for lab, d in labels_data]]}
# ------------------------------------------------------------------------------------------------ scenario
def test_parse_scenario_valid_sorted_and_normalized():
sc = parse_scenario(
_jsonl(
{"day": 2, "note": "тошнота", "actions": [{"advance_to": "8:00"}, {"press": "Принял"}]},
"",
"# comment",
{"day": 0, "actions": [{"start": None}, {"text": "привет"}, {"expect_color": "GREEN"}]},
)
)
assert sc.errors == []
assert sc.day_numbers == [0, 2]
assert sc.day(2).actions == (Action("advance_to", "08:00"), Action("press", "Принял"))
assert sc.day(0).actions[0] == Action("start", "")
assert sc.day(0).actions[2] == Action("expect_color", "green")
assert sc.day(2).note == "тошнота"
def test_parse_scenario_reports_and_skips_bad_parts():
sc = parse_scenario(
_jsonl(
"{not json",
[1, 2],
{"day": -1, "actions": []},
{"day": 1, "actions": [{"advance_to": "25:00"}, {"press": "Да"}, {"fly": "x"}, {"a": 1, "b": 2}]},
{"day": 1, "actions": [{"text": "дубль"}]},
{"day": 3, "actions": "oops"},
{"day": 4, "actions": [{"expect_color": "purple"}, {"text": " "}]},
)
)
assert sc.day_numbers == [1, 4]
assert sc.day(1).actions == (Action("press", "Да"),)
assert sc.day(4).actions == ()
assert len(sc.errors) == 10
assert any("строка 1" in e and "не JSON" in e for e in sc.errors)
assert any("повторяется" in e for e in sc.errors)
assert any("ЧЧ:ММ" in e for e in sc.errors)
@pytest.mark.parametrize("raw", [{"press": 5}, "press", {"advance_to": "8"}, {}])
def test_parse_action_rejects(raw):
with pytest.raises(ValueError):
parse_action(raw)
def test_load_scenario_missing_and_present(tmp_path):
missing = load_scenario("nope.jsonl", root=tmp_path)
assert missing.days == [] and "не найден" in missing.errors[0]
(tmp_path / "s.jsonl").write_text(_jsonl({"day": 5, "actions": [{"text": "брошу"}]}), encoding="utf-8")
assert load_scenario("s.jsonl", root=tmp_path).day_numbers == [5]
# ------------------------------------------------------------------------------------------------ press resolver
def test_resolve_press_newest_reply_first_and_exact_beats_substring():
replies = [
_reply("вчера", ("Принял", "med:taken:1")),
_reply("сегодня", ("Принял позже", "med:later:2"), ("Принял", "med:taken:2")),
]
assert resolve_press(replies, "принял") == ("med:taken:2", "Принял")
assert resolve_press(replies, "позже") == ("med:later:2", "Принял позже")
def test_resolve_press_window_normalization_and_miss():
old = _reply("старое", ("Ещё вопрос", "q:1"))
filler = [_reply(f"r{i}") for i in range(10)]
assert resolve_press([old, *filler], "еще") is None # outside the 10-reply window
assert resolve_press([old, *filler[:3]], " ЕЩЕ вопрос ") == ("q:1", "Ещё вопрос")
assert resolve_press([old], "нет такой") is None
assert resolve_press([old], "") is None
# ------------------------------------------------------------------------------------------------ clock math
def test_target_datetime_next_occurrence_without_planned_date():
now = datetime(2026, 10, 5, 7, 0, tzinfo=MSK)
assert target_datetime(now, "08:00", None) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
late = datetime(2026, 10, 5, 21, 0, tzinfo=MSK)
assert target_datetime(late, "08:00", None) == datetime(2026, 10, 6, 8, 0, tzinfo=MSK)
# Just reached (virtual time ticked a bit past): stays today, no rollover.
just = datetime(2026, 10, 5, 8, 2, tzinfo=MSK)
assert target_datetime(just, "08:00", None) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
def test_target_datetime_with_planned_date_and_hours_until():
now = datetime(2026, 10, 5, 20, 30, tzinfo=MSK)
target = target_datetime(now, "08:00", date(2026, 10, 6))
assert hours_until(now, target) == pytest.approx(11.5)
assert hours_until(now, now - timedelta(minutes=3)) == 0.0
assert hours_until(now, now - timedelta(hours=2)) is None
def test_planned_date_chains_days_and_ignores_stale_dates():
dates = {0: date(2026, 10, 5), 2: date(2026, 10, 7)}
assert planned_date(dates, 5, today=date(2026, 10, 7)) == date(2026, 10, 10)
assert planned_date(dates, 2, today=date(2026, 10, 7)) == date(2026, 10, 7)
assert planned_date({}, 3, today=date(2026, 10, 7)) is None
# Presenter moved the clock manually past the chained date → fall back to "next occurrence".
assert planned_date(dates, 3, today=date(2026, 10, 12)) is None
def test_parse_clock_now():
assert parse_clock_now({"now": "2026-10-05T08:00:00+03:00"}) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
with pytest.raises(ValueError):
parse_clock_now({})
# ------------------------------------------------------------------------------------------------ chat log
def test_chatlog_normalizes_and_tracks_active_keyboard():
assert normalize_reply({"text": None, "buttons": [[{"label": "x"}], "bad"]}) == {
"dir": "out",
"text": "",
"kind": "normal",
"buttons": [],
}
chat = ChatLog()
chat.add_replies([_reply("a", ("Да", "y")), {"text": "b"}])
chat.add_in("Да", pressed=True)
assert chat.last_buttons_index() == 0
assert [r["text"] for r in chat.recent_replies(1)] == ["b"]
chat.add_history([{"direction": "in", "text": "hi"}, {"direction": "out", "text": "yo", "kind": "info"}])
assert chat.entries[-1]["kind"] == "info" and chat.entries[-2]["dir"] == "in"
# ------------------------------------------------------------------------------------------------ runner
class FakeApi:
"""Demo backend double: a virtual clock with scheduled pushes and scripted dialog answers."""
def __init__(self, now: datetime):
self.now = now
self.calls: list[tuple] = []
self.scheduled: list[tuple[datetime, dict[str, Any]]] = []
self.box: list[dict[str, Any]] = []
self.color = "green"
self.fail_on: str | None = None
self.answers: dict[str, list[dict[str, Any]]] = {}
def _maybe_fail(self, what: str) -> None:
if self.fail_on == what:
raise ApiError(f"{what}: HTTP 500")
def push_at(self, hhmm: str, day: date, reply: dict[str, Any]) -> None:
h, m = map(int, hhmm.split(":"))
self.scheduled.append((datetime(day.year, day.month, day.day, h, m, tzinfo=MSK), reply))
def clock(self) -> dict[str, Any]:
self._maybe_fail("clock")
return {"now": self.now.isoformat(), "virtual": True, "offset_days": 0}
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]:
self._maybe_fail("advance")
self.calls.append(("advance", round(hours, 3)))
self.now += timedelta(days=days, hours=hours)
due = [(t, r) for t, r in self.scheduled if t <= self.now]
self.scheduled = [(t, r) for t, r in self.scheduled if t > self.now]
self.box.extend(r for _, r in due)
return self.clock()
def send_event(self, token, type_, *, text=None, button=None, params=None):
self._maybe_fail(type_)
self.calls.append((type_, text or button or params))
return self.answers.get(text or button or type_, [{"text": "ок"}])
def outbox(self, token):
out, self.box = self.box, []
return out
def patients(self, doctor):
self._maybe_fail("patients")
return [{"token": "tok-other", "status_color": "red"}, {"token": "tok-andrey", "status_color": self.color}]
def _runner(api: FakeApi, state: PlaybackState | None = None, chat: ChatLog | None = None) -> PlaybackRunner:
sleeps: list[float] = []
r = PlaybackRunner(api, "tok-andrey", chat or ChatLog(), state or PlaybackState(), delay=0.5, sleep=sleeps.append)
r.sleeps = sleeps # type: ignore[attr-defined]
return r
SCENARIO = parse_scenario(
_jsonl(
{"day": 1, "note": "приём", "actions": [{"advance_to": "08:00"}, {"press": "Принял"}]},
{
"day": 2,
"actions": [
{"advance_to": "08:00"},
{"press": "принял"},
{"advance_to": "20:00"},
{"text": "подташнивает с утра"},
{"expect_color": "yellow"},
],
},
{"day": 5, "actions": [{"advance_to": "20:00"}, {"text": "брошу"}]},
)
)
def test_runner_plays_day_in_order_with_dynamic_button_data():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
api.push_at("08:00", date(2026, 10, 6), _reply("Пора принять сертралин", ("Принял", "med:taken:42")))
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 1))
assert api.calls == [("advance", 11.0), ("button", "med:taken:42")]
assert [log.status for log in logs] == ["info", "ok", "ok", "ok"]
assert runner.state.done == [1] and runner.state.dates[1] == date(2026, 10, 6)
assert runner.chat.entries[1] == {"dir": "in", "text": "Принял", "pressed": True}
assert runner.sleeps == [0.5] # one pause between two steps
def test_runner_chains_next_day_date_and_soft_color_check():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
api.push_at("08:00", date(2026, 10, 6), _reply("доза 1", ("Принял", "med:taken:1")))
api.push_at("08:00", date(2026, 10, 7), _reply("доза 2", ("Принял", "med:taken:2")))
runner = _runner(api)
list(runner.play_day(SCENARIO, 1))
logs = list(runner.play_day(SCENARIO, 2))
assert api.calls[2:] == [
("advance", 24.0),
("button", "med:taken:2"),
("advance", 12.0),
("text", "подташнивает с утра"),
]
color_log = logs[-2]
assert color_log.status == "warn" and "green" in color_log.message
assert runner.state.done == [1, 2] and runner.state.failed is None
def test_scroll_to_plays_all_unplayed_days_fast():
api = FakeApi(datetime(2026, 10, 5, 7, 0, tzinfo=MSK))
api.color = "yellow"
for d in (5, 6):
api.push_at("08:00", date(2026, 10, d), _reply("доза", ("Принял", f"med:taken:{d}")))
runner = _runner(api)
runner.delay = 0
logs = list(runner.scroll_to(SCENARIO, 5))
assert runner.state.done == [1, 2, 5]
# Day 1 lands on today 08:00 (nothing played yet), day 5 is chained: 5 − 2 = 3 days after day 2.
assert runner.state.dates == {1: date(2026, 10, 5), 2: date(2026, 10, 6), 5: date(2026, 10, 9)}
assert all(log.status != "fail" for log in logs)
assert runner.sleeps == []
assert list(runner.scroll_to(SCENARIO, 5))[0].status == "info" # nothing left to scroll
def test_missing_button_pauses_and_resume_can_skip_or_retry():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK)) # no reminder scheduled → button never shows up
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 1))
assert logs[-1].status == "fail" and "не найдена" in logs[-1].message
assert runner.state.failed is not None and (runner.state.failed.day, runner.state.failed.step) == (1, 1)
assert runner.state.done == []
assert runner.sleeps == [0.5] + [runner.retry_wait] * runner.press_retries # step pause, then retries
api.box.append(_reply("доза", ("Принял", "med:taken:7")))
retry = list(runner.resume(SCENARIO, skip=False))
assert retry[-1].status == "ok" and ("button", "med:taken:7") in api.calls
assert runner.state.failed is None and runner.state.done == [1]
def test_resume_skip_continues_until_target_day():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
runner = _runner(api)
runner.delay = 0
list(runner.scroll_to(SCENARIO, 2)) # fails on day 1 «Принял»
assert runner.state.failed.until == 2
list(runner.resume(SCENARIO, skip=True)) # skip the press, play day 1 rest and day 2 → fails on day 2 press
assert runner.state.done == [1]
assert (runner.state.failed.day, runner.state.failed.step) == (2, 1)
def test_api_errors_never_escape_the_runner():
api = FakeApi(datetime(2026, 10, 5, 19, 0, tzinfo=MSK))
api.fail_on = "text"
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 5))
assert logs[-1].status == "fail" and "HTTP 500" in logs[-1].message
assert runner.state.failed is not None
api.fail_on = "clock"
logs = list(_runner(api).play_day(SCENARIO, 5))
assert logs[-1].status == "fail" and "часы" in logs[-1].message
api.fail_on = "patients"
color_only = parse_scenario(_jsonl({"day": 0, "actions": [{"expect_color": "green"}]}))
logs = list(_runner(api).play_day(color_only, 0))
assert logs[1].status == "warn" and logs[-1].status == "ok"
def test_past_target_is_skipped_with_warning():
api = FakeApi(datetime(2026, 10, 6, 21, 0, tzinfo=MSK))
state = PlaybackState(dates={5: date(2026, 10, 6)})
logs = list(_runner(api, state).play_day(SCENARIO, 5)) # replaying day 5 after 20:00 the same evening
assert logs[1].status == "warn" and "уже прошло" in logs[1].message
assert ("text", "брошу") in api.calls and not any(c[0] == "advance" for c in api.calls)
def test_play_unknown_day_is_info():
logs = list(_runner(FakeApi(datetime(2026, 10, 5, 8, 0, tzinfo=MSK))).play_day(SCENARIO, 42))
assert [log.status for log in logs] == ["info"]
def test_start_action_sends_code():
api = FakeApi(datetime(2026, 10, 5, 8, 0, tzinfo=MSK))
sc = parse_scenario(_jsonl({"day": 0, "actions": [{"start": "DEMO-0001"}, {"start": ""}]}))
list(_runner(api).play_day(sc, 0))
assert api.calls == [("start", {"code": "DEMO-0001"}), ("start", {})]
# ------------------------------------------------------------------------------------------------ http client
def _client(handler) -> ConsoleApi:
return ConsoleApi("http://api", client=httpx.Client(transport=httpx.MockTransport(handler)))
def test_console_api_paths_and_bodies():
seen: list[tuple[str, str, Any]] = []
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content) if req.content else None
seen.append((req.method, req.url.path, body))
if req.url.path.endswith("/events"):
return httpx.Response(200, json={"replies": [{"text": "hi"}]})
if req.url.path.endswith("/outbox"):
return httpx.Response(200, json={"replies": []})
return httpx.Response(200, json={"now": "2026-10-05T08:00:00+03:00"})
api = _client(handler)
assert api.send_event("tok-andrey", "button", button="med:taken:1") == [{"text": "hi"}]
assert api.outbox("tok-andrey") == []
api.advance(hours=1.5)
assert seen == [
("POST", "/api/v1/dialog/tok-andrey/events", {"type": "button", "params": {}, "button": "med:taken:1"}),
("GET", "/api/v1/dialog/tok-andrey/outbox", None),
("POST", "/api/v1/demo/clock/advance", {"days": 0, "hours": 1.5}),
]
def test_console_api_errors_become_api_error_and_personas_fall_back(monkeypatch):
def down(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
api = _client(down)
with pytest.raises(ApiError, match="недоступен"):
api.clock()
assert api.health() is False
monkeypatch.setenv("DEMO_PERSONA_TOKEN", "tok-andrey")
personas, error = personas_or_fallback(api)
assert personas[0]["token"] == "tok-andrey" and error
api = _client(lambda req: httpx.Response(500))
with pytest.raises(ApiError, match="HTTP 500"):
api.outbox("t")
ok = _client(lambda req: httpx.Response(200, json=[{"token": "tok-andrey", "display_name": "Андрей С."}]))
personas, error = personas_or_fallback(ok)
assert error is None and personas[0]["display_name"] == "Андрей С."
def test_personas_fall_back_to_doctor_patients_with_hero_first():
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path.endswith("/demo/personas"):
return httpx.Response(200, json=[])
return httpx.Response(
200, json=[{"token": "h1", "display_name": "Сергей П."}, {"token": "h2", "display_name": "Андрей С."}]
)
personas, error = personas_or_fallback(_client(handler))
assert [p["token"] for p in personas] == ["h2", "h1"] and "пациенты врача" in error
def test_scenario_path_guessed_from_name_or_token(tmp_path):
(tmp_path / "data" / "synthetic").mkdir(parents=True)
(tmp_path / "data" / "synthetic" / "andrey.jsonl").write_text("", encoding="utf-8")
assert persona_slug("Андрей С.") == "andrey" and persona_slug("Наталья Ф.") == "natalya"
assert default_scenario_path({"token": "h", "display_name": "Андрей С."}, tmp_path) == "data/synthetic/andrey.jsonl"
assert default_scenario_path({"token": "tok-andrey"}, tmp_path) == "data/synthetic/andrey.jsonl"
assert default_scenario_path({"token": "x", "display_name": "Ольга Н."}, tmp_path) is None
assert default_scenario_path({"token": "x", "scenario": "s.jsonl"}, tmp_path) == "s.jsonl"
+64
View File
@@ -0,0 +1,64 @@
"""Demo console pages (#17) render without crashing even when the API is down (robustness of the live demo)."""
from pathlib import Path
import pytest
from streamlit.testing.v1 import AppTest
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
@pytest.fixture
def api_down(monkeypatch):
monkeypatch.setenv("API_BASE_URL", "http://127.0.0.1:9") # nothing listens on the discard port
monkeypatch.setenv("DEMO_PERSONA_TOKEN", "tok-andrey")
monkeypatch.delenv("PATIENT_WEB_URL", raising=False)
def _page(name: str) -> AppTest:
return AppTest.from_file(str(PAGES / name), default_timeout=30)
def test_console_page_survives_api_down_and_button_clicks(api_down, tmp_path):
scenario = tmp_path / "s.jsonl"
scenario.write_text('{"day": 1, "note": "утро", "actions": [{"advance_to": "08:00"}]}\n', encoding="utf-8")
at = _page("2_Демо-консоль.py")
at.session_state["console_scenario_path:tok-andrey"] = str(scenario)
at.run()
assert not at.exception
assert any("Часы недоступны" in w.value for w in at.warning)
labels = [b.label for b in at.button]
assert {"+1 час", "Следующий день", "🚀 Начать", "▶ Проиграть день 1"} <= set(labels)
at.button(key="console-clock-d1").click().run() # «Следующий день» with the API down
assert not at.exception
at.button(key="console-chat-start").click().run()
assert not at.exception
next(b for b in at.button if b.label == "▶ Проиграть день 1").click().run()
assert not at.exception
state = at.session_state["console_playback:tok-andrey"]
assert state.failed is not None and state.done == [] # playback paused, page alive
at.run()
assert not at.exception
assert any("Повторить шаг" in b.label for b in at.button), [b.label for b in at.button]
def test_console_page_without_scenario_file(api_down):
at = _page("2_Демо-консоль.py")
at.session_state["console_scenario_path:tok-andrey"] = "data/synthetic/__missing__.jsonl"
at.run()
assert not at.exception
assert any("Сценарий не загружен" in i.value for i in at.info)
def test_split_screen_survives_api_down_and_uses_iframe_when_configured(api_down, monkeypatch):
at = _page("3_Сплит-экран.py")
at.run()
assert not at.exception
assert any("Алерты недоступны" in w.value for w in at.warning)
monkeypatch.setenv("PATIENT_WEB_URL", "http://localhost:5173")
at = _page("3_Сплит-экран.py")
at.run()
assert not at.exception
assert any("http://localhost:5173" in c.value for c in at.caption)
+227
View File
@@ -0,0 +1,227 @@
"""Pure dashboard logic: ordering, labels, dose strip, scales config, SNILS, chart builders, page discovery."""
import random
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from app.api import fixtures
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, SeriesPoint
from dashboard.app import discover_pages, slugify
from dashboard.components import charts, ui
from dashboard.components import helpers as h
TZ = ZoneInfo("Europe/Moscow")
def _risk(score: int, level: str = "low", factors: list[str] | None = None) -> DiscontinuationRisk:
return DiscontinuationRisk(score=score, level=level, factors=factors or [])
def test_sort_red_yellow_grey_green_then_risk():
items = list(reversed(fixtures.PATIENTS))
items.append(fixtures.PATIENTS[1].model_copy(update={"token": "g", "display_name": "Г", "status_color": "grey"}))
out = h.sort_patients(items)
colors = [p.status_color for p in out]
assert colors == sorted(colors, key=h.STATUS_ORDER.__getitem__)
assert out[0].token == "tok-andrey"
yellows = [p for p in out if p.status_color == "yellow"]
assert yellows[0].display_name == "Сергей П." # highest discontinuation risk among yellow
assert [p.discontinuation_risk.score for p in yellows] == sorted(
(p.discontinuation_risk.score for p in yellows), reverse=True
)
def test_filter_and_kpis():
items = fixtures.PATIENTS
assert {p.status_color for p in h.filter_patients(items, "yellow")} == {"yellow"}
assert [p.display_name for p in h.filter_patients(items, query="андр")] == ["Андрей С."]
assert len(h.filter_patients(items, query="пароксетин")) == 1
k = h.kpis(items)
assert (k.total, k.red, k.yellow, k.green, k.grey, k.high_risk) == (8, 1, 4, 3, 0, 1)
def test_trend_arrows():
assert h.trend_view("up").text == "↑" and h.trend_view("up").tone == "red"
assert h.trend_view("down").text == "↓" and h.trend_view("down").tone == "green"
assert h.trend_view("flat").text == "→"
assert h.trend_view("unknown").tone == "grey"
def test_risk_badge_and_tooltip():
v = h.risk_view(_risk(70, "high", ["пропуски 3 из 7", "нет ответа 2 дня"]))
assert v.text == "Высокий · 70" and v.tone == "red"
assert "• пропуски 3 из 7\n• нет ответа 2 дня" in v.hint
assert h.risk_view(_risk(35, "medium")).tone == "yellow"
assert "значимых факторов нет" in h.risk_view(_risk(5)).hint
def test_adherence_thresholds():
assert h.adherence_view(None).text == "—"
assert h.adherence_view(1.0).tone == "green"
assert h.adherence_view(0.8).tone == "green"
assert h.adherence_view(0.7).tone == "yellow"
assert h.adherence_view(0.57) == h.View("57%", "red")
def test_level_tone_matches_adr006():
assert h.level_tone("acute") == "red"
assert h.level_tone("high") == h.level_tone("moderate") == "yellow"
assert h.level_tone("info") == "grey"
def test_severity_bands_come_from_config():
bands = h.severity_bands("phq9")
assert [b.lo for b in bands] == [0, 5, 10, 15, 20]
assert h.severity_label("phq9", 18) == "Умеренно-тяжёлая"
assert h.severity_label("pcl5", 44) == "Вероятное ПТСР"
assert h.severity_label("pcl5", None) == ""
assert h.severity_bands("no_such_scale") == ()
def test_last_and_prev():
card = fixtures.andrey_card()
assert h.last_and_prev(card.series, "phq9") == (18, 13)
assert h.last_and_prev(card.series, "gad7") == (None, None)
def test_dose_grid_fills_gaps_and_counts():
d0 = datetime(2026, 9, 21, 8, tzinfo=TZ)
doses = [
DoseOut(planned_at=d0, drug="сертралин", status="taken"),
DoseOut(planned_at=d0 + timedelta(hours=12), drug="сертралин", status="missed"),
DoseOut(planned_at=d0 + timedelta(days=2), drug="сертралин", status="skipped"),
DoseOut(planned_at=d0 + timedelta(days=3), drug="сертралин", status="pending"),
]
g = h.dose_grid(doses)
assert g.days == [date(2026, 9, 21) + timedelta(days=i) for i in range(4)]
assert g.slots == ["08:00", "20:00"]
assert g.cells[("20:00", date(2026, 9, 21))] == "missed"
assert ("08:00", date(2026, 9, 22)) not in g.cells # no dose planned that day → empty cell
assert g.counts == {"taken": 1, "missed": 1, "skipped": 1, "pending": 1}
assert g.adherence == 1 / 3 # pending is not counted
def test_dose_grid_empty():
g = h.dose_grid([])
assert g.days == [] and g.adherence is None
def test_dose_grid_converts_to_moscow_time():
utc = datetime(2026, 9, 21, 5, tzinfo=ZoneInfo("UTC"))
g = h.dose_grid([DoseOut(planned_at=utc, drug="x", status="taken")])
assert g.slots == ["08:00"]
def _alert(i: int, level: str = "moderate", acked: bool = False, hours: int = 0) -> AlertOut:
ts = datetime(2026, 10, 3, 9, tzinfo=TZ) + timedelta(hours=hours)
return AlertOut(
id=i,
token="t",
patient_name="П",
ts=ts,
level=level,
color=h.level_tone(level),
code="c",
reason="r",
acked_by="d" if acked else None,
acked_at=ts if acked else None,
)
def test_alert_ordering_new_and_local_acks():
alerts = [_alert(1, "moderate", hours=5), _alert(2, "acute", hours=1), _alert(3, "high", acked=True, hours=9)]
assert [a.id for a in h.sort_alerts(alerts)] == [2, 1, 3]
assert [a.id for a in h.split_new(alerts, {1})] == [2, 3]
at = datetime(2026, 10, 3, 22, tzinfo=TZ)
out = h.apply_local_acks(alerts, {2: ("doc-smirnova", at)})
assert out[1].acked_by == "doc-smirnova" and out[1].acked_at == at
assert out[0].acked_at is None
def test_russian_formatting():
assert [h.age_label(n) for n in (21, 23, 34, 41, 45, 11, 112)] == [
"21 год",
"23 года",
"34 года",
"41 год",
"45 лет",
"11 лет",
"112 лет",
]
assert h.age_label(0) == "возраст не указан"
assert h.absolute_link("/console?code=X", "http://h:8501/") == "http://h:8501/console?code=X"
assert h.absolute_link("https://t.me/b?start=X", "http://h") == "https://t.me/b?start=X"
today = date(2026, 10, 3)
assert h.visit_label(date(2026, 10, 18), today) == "визит 18 окт · через 15 дней"
assert h.visit_label(date(2026, 10, 4), today) == "визит 4 окт · через 1 день"
assert h.visit_label(today, today) == "визит сегодня"
assert "просрочен" in h.visit_label(date(2026, 10, 1), today)
assert h.visit_label(None, today) == "визит не назначен"
assert h.when_label(datetime(2026, 10, 3, 21, tzinfo=TZ), today) == "сегодня, 21:00"
def test_snils_checksum():
assert h.snils_checksum("112233445") == 95 # well-known valid example 112-233-445 95
assert h.is_valid_snils("112-233-445 95")
assert not h.is_valid_snils("112-233-445 96")
rng = random.Random(1)
for _ in range(50):
s = h.synthetic_snils(rng)
assert s.startswith("000-") and h.is_valid_snils(s)
def test_ui_row_escapes_and_shows_values():
p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"})
html = ui.patient_row(p, detailed=True)
assert "<script>" not in html and "&lt;script&gt;" in html
assert "Андрей С." in html and "день терапии 13" in html and "↑" in html
def test_home_fragments_escape_api_values():
payload = '<img src=x onerror="alert(1)">'
p = fixtures.PATIENTS[0].model_copy(
update={
"display_name": payload,
"drug": payload,
"last_event": payload,
"discontinuation_risk": _risk(70, "high", [payload]),
}
)
for html in (ui.patient_row(p), ui.patient_row(p, detailed=True), ui.workspace_header(payload, payload)):
assert "<img" not in html
assert "&lt;img" in html
def test_scale_figure_marks_unscheduled_and_bands():
card = fixtures.andrey_card()
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
assert fig is not None
trace = fig.data[0]
assert list(trace.marker.symbol) == ["circle", "circle", "diamond-open"]
assert len(fig.layout.shapes) == 5 # severity bands from config/scales/phq9.yaml
assert charts.scale_figure([], "phq9", "PHQ-9") is None
def test_wellbeing_figure_small_multiples():
card = fixtures.andrey_card()
fig = charts.wellbeing_figure(card.series)
assert fig is not None and len(fig.data) == 3
assert charts.wellbeing_figure([SeriesPoint(day=date(2026, 1, 1), metric="phq9", value=3)]) is None
def test_page_discovery_orders_and_sections(tmp_path: Path):
for name in ("1_Пациенты.py", "1b_Подключить_пациента.py", "2_Демо-консоль.py", "3_Сплит-экран.py", "helper.py"):
(tmp_path / name).write_text("", encoding="utf-8")
(tmp_path / "4_Что-то.py").write_text('NAV_ICON = ":material/star:"\nNAV_SECTION = "Прочее"\n', encoding="utf-8")
specs = discover_pages(tmp_path)
assert [s.title for s in specs] == ["Пациенты", "Подключить пациента", "Демо-консоль", "Сплит-экран", "Что-то"]
assert [s.section for s in specs][:4] == ["Кабинет врача", "Кабинет врача", "Демо", "Демо"]
assert specs[4].icon == ":material/star:" and specs[4].section == "Прочее"
assert specs[2].url_path == "demo-konsol"
assert slugify("Сплит-экран") == "split-ekran"
def test_real_pages_dir_has_doctor_pages():
titles = [s.title for s in discover_pages()]
assert titles[:2] == ["Пациенты", "Подключить пациента"]
+228
View File
@@ -0,0 +1,228 @@
"""Streamlit AppTest smoke tests of the doctor pages against an in-memory API (fixtures over httpx.MockTransport)."""
from pathlib import Path
import httpx
import pytest
from streamlit.testing.v1 import AppTest
from app.api import fixtures
from dashboard.api_client import ApiClient
from dashboard.components import state
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
LIST_PAGE = str(PAGES / "1_Пациенты.py")
CONNECT_PAGE = str(PAGES / "1b_Подключить_пациента.py")
def _dump(obj):
return [o.model_dump(mode="json") for o in obj] if isinstance(obj, list) else obj.model_dump(mode="json")
class FakeApi:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
self.extra_alerts: list = []
self.patients = list(fixtures.PATIENTS)
def __call__(self, req: httpx.Request) -> httpx.Response:
path = req.url.path.removeprefix("/api/v1")
self.calls.append((req.method, path))
if path == "/doctors/doc-smirnova/patients":
return httpx.Response(200, json=_dump(self.patients))
if path.startswith("/patients/tok-"):
card = fixtures.card(path.rsplit("/", 1)[1])
return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404)
if path == "/doctors/doc-smirnova/alerts":
return httpx.Response(200, json=_dump(self.extra_alerts + fixtures.all_alerts()))
if path.startswith("/alerts/") and path.endswith("/ack"):
alert = next(a for a in fixtures.all_alerts() if a.id == int(path.split("/")[2]))
acked = alert.model_copy(update={"acked_by": "doc-smirnova", "acked_at": alert.ts})
return httpx.Response(200, json=_dump(acked))
if path == "/patients/connect":
return httpx.Response(
200, json={"token": "tok-new", "code": "DEMO-0001", "deep_link": "https://t.me/b?start=DEMO-0001"}
)
if path == "/demo/clock":
return httpx.Response(200, json={"now": "2026-10-03T21:05:00+03:00", "virtual": True, "offset_days": 0})
return httpx.Response(404)
@pytest.fixture
def api(monkeypatch) -> FakeApi:
fake = FakeApi()
client = ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(fake))
monkeypatch.setattr(state, "client", lambda: client)
return fake
def _html(at: AppTest) -> str:
return "\n".join(str(el.proto.body) for el in at.get("html"))
def test_list_page_renders_sorted_patients(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
html = _html(at)
assert "Кабинет д-ра Смирновой Е. А." in html
assert "Пациенты под наблюдением" in html
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
assert "Высокий · 70" in html # discontinuation-risk badge
open_buttons = [b for b in at.button if b.label == "Открыть"]
assert len(open_buttons) == len(fixtures.PATIENTS)
def test_list_filter_and_open_card(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Срочно").run()
at.text_input(key="patient_query").set_value("андр").run()
at.toggle(key="patient_details").set_value(True).run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
at.button(key="open-tok-andrey").click().run()
assert not at.exception
assert at.query_params["patient"] in ("tok-andrey", ["tok-andrey"])
html = _html(at)
assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html
assert "Решения принимает врач" in html
assert ("GET", "/patients/tok-andrey") in api.calls
next(b for b in at.button if b.label == "Все пациенты").click().run()
assert not at.exception
assert not at.query_params.get("patient")
assert len([b for b in at.button if b.label == "Открыть"]) == 1 # the selected filter is retained
assert at.text_input(key="patient_query").value == "андр"
assert at.toggle(key="patient_details").value is True
@pytest.mark.parametrize("choice,expected", [("Внимание", 4), ("Без уведомлений", 3), ("Все пациенты", 8)])
def test_home_status_filters(api, choice, expected):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value(choice).run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == expected
assert f"Показано {expected} из 8 пациентов" in _html(at)
def test_home_search_empty_result_and_reset(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.text_input(key="patient_query").set_value(" ПАРОКСЕТИН ").run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Сергей П." in _html(at)
at.segmented_control(key="status_filter").set_value("Без уведомлений").run()
assert "Пациенты не найдены" in _html(at)
next(b for b in at.button if b.label == "Сбросить фильтры").click().run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 8
assert at.text_input(key="patient_query").value == ""
def test_home_empty_cabinet_keeps_connect_action(api):
api.patients = []
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "Пациентов пока нет" in _html(at)
assert "Показано 0 из 0 пациентов" in _html(at)
assert any(b.label == "Подключить пациента" for b in at.button)
assert not any(b.label == "Открыть" for b in at.button)
def test_home_details_preserve_clinical_information(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert "PHQ-9" in _html(at) and "PCL-5" in _html(at)
at.toggle(key="patient_details").set_value(True).run()
assert not at.exception
html = _html(at)
assert "сертралин 50 мг" in html
assert "PHQ-9 п.9 = 2 — кризисный протокол" in html
assert "пропуски 3 из 7" in html
def test_home_info_only_patient_and_missing_scores(api):
api.patients = [
fixtures.PATIENTS[0].model_copy(
update={
"status_color": "grey",
"top_alert_level": "info",
"phq9_last": None,
"pcl5_last": None,
"adherence_7d": None,
"open_alerts": 1,
}
)
]
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Нет ответа").run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Из них только инфо: 1" in _html(at)
assert 'mt-dim">—' in _html(at)
def test_card_ack_posts_and_marks_alert(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20)
at.query_params["patient"] = "tok-andrey"
at.run()
assert not at.exception
assert at.button(key="ack-3").label == "Подтвердить"
at.button(key="ack-3").click().run()
assert ("POST", "/alerts/3/ack") in api.calls
assert not [b for b in at.button if b.key == "ack-3"] # acked alert has no button any more
def test_api_down_shows_banner(monkeypatch):
def refuse(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=req)
client = ApiClient("http://api.test", transport=httpx.MockTransport(refuse))
monkeypatch.setattr(state, "client", lambda: client)
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "API недоступен" in _html(at)
assert any(b.label == "Повторить загрузку" for b in at.button)
assert "Всего пациентов" not in _html(at) # failed loading is not a zero-patient cabinet
def test_connect_flow_shows_code(api):
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
assert not at.exception
at.button[0].click().run() # «Заполнить демо-данными»
at.checkbox(key="cp_consent").check().run()
assert at.text_input(key="cp_name").value == "Андрей С."
submit = next(b for b in at.button if b.label == "Подключить и получить код")
submit.click().run()
assert not at.exception
assert ("POST", "/patients/connect") in api.calls
assert "DEMO-0001" in _html(at)
assert at.get("image") # QR image
def test_connect_requires_consent(api):
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
at.text_input(key="cp_name").set_value("Пётр К.").run()
next(b for b in at.button if b.label == "Подключить и получить код").click().run()
assert at.error and "согласие" in at.error[0].value
assert ("POST", "/patients/connect") not in api.calls
def test_app_entry_navigation_and_acute_banner(api):
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
assert not at.exception
html = _html(at)
assert "Кризисный сигнал · Андрей С." in html # open acute alert pinned above every page
assert "Кабинет д-ра Смирновой Е. А." in html # default page = patient list
assert "Прототип на синтетических данных" in html
assert ("GET", "/doctors/doc-smirnova/alerts") in api.calls
def test_new_alert_toasts_once(api):
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
assert not at.toast # alerts open at session start are not "new"
new = fixtures.all_alerts()[0].model_copy(
update={"id": 10, "token": "tok-sergey", "patient_name": "Сергей П.", "reason": "C-SSRS: намерение"}
)
api.extra_alerts.append(new)
at.run()
assert not at.exception
assert any("Сергей П." in t.value for t in at.toast)
assert "Кризисный сигнал · Сергей П." in _html(at)
at.run()
assert not any("Сергей П." in t.value for t in at.toast)
+188
View File
@@ -0,0 +1,188 @@
"""Knowledge base in the dialog (#64): «Вопрос о лечении» menu, questions in idle free text, routing order
(safety → check-in signals → drug question → guidelines → support)."""
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
import pytest
from app.config import Settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.knowledge import QUESTION_STATE, load_knowledge_texts, looks_like_question
from app.integrations.base import DrugSection, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.med_qa import load_med_qa_texts
from app.llm.mock import MockTasks
from app.llm.rag import fallback_text
from app.llm.rag import qa as rag_qa
from app.llm.texts import render_phrase
from app.services.contracts import NoopMonitoring
TOKEN = "k" * 32
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 7, 8, 0)
SUPPORT = render_phrase("support", {})
KB = load_knowledge_texts()
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
text = "Улучшение обычно отмечается через 2–4 недели регулярного приёма."
return [DrugSection(inn=inn, section="onset", text=text, source="синтетика")]
@pytest.fixture
async def engine(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Engine]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
monkeypatch.setattr(rag_qa, "get_gateway", lambda: LLMGateway(MockTasks()))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
yield Engine(gateway=LLMGateway(MockTasks()), monitoring=NoopMonitoring())
clock.reset()
await session.configure()
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def say(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def joined(replies: list[Reply]) -> str:
return "\n".join(r.text for r in replies)
# --- question detection --------------------------------------------------------------------------------
@pytest.mark.parametrize(
("text", "expected"),
[
("что такое флэшбек", True),
("Можно ли вылечить ПТСР", True),
("кошмары снятся, это нормально?", True),
("сегодня 6 из 10", False),
("спал плохо", False),
("какой-то день", False), # "как" only as a whole word at the start
("как дела?", False), # small talk
("Привет! Как ты?", False),
("[NAME], что такое ПТСР", True), # scrub placeholder before the question
],
)
def test_looks_like_question(text: str, expected: bool) -> None:
assert looks_like_question(text) is expected
# --- menu «Вопрос о лечении» ------------------------------------------------------------------------------
async def test_menu_has_knowledge_button(engine: Engine) -> None:
r = await engine.handle(TOKEN, Event(type="start"))
assert "menu:kb_question" in buttons(r)
async def test_menu_question_answers_from_guidelines(engine: Engine) -> None:
r = await press(engine, "menu:kb_question")
assert joined(r) == KB.prompt
assert (await state())[0] == QUESTION_STATE
r = await say(engine, "что такое флэшбек?")
text = joined(r)
assert "Флэшбек (flashback" in text
assert "Источник: КР ПТСР 2023, стр. 2" in text
assert rag_qa.load_rag_texts()["patient"].doctor_note in text
assert "menu:kb_question" in buttons(r) # back to the menu
assert (await state())[0] == "idle"
async def test_menu_question_without_answer_sends_to_doctor(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, "какой рецепт борща")
assert fallback_text("patient") in joined(r)
assert (await state())[0] == "idle"
@pytest.mark.parametrize("question", ["можно увеличить дозу?", "можно пить полтаблетки?"])
async def test_dose_question_redirected_to_doctor(engine: Engine, question: str) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, question)
assert joined(r).startswith(KB.dose_redirect)
assert "клинических рекомендациях" not in joined(r)
async def test_button_instead_of_question_leaves_the_flow(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await press(engine, "menu:contact_doctor")
assert "врач" in joined(r)
assert (await state())[0] == "idle"
async def test_scrubbed_question_still_answered(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, "Иван Петров, 89161234567, что такое ПТСР?")
assert "ПТСР – это расстройство" in joined(r)
# --- free text in idle ------------------------------------------------------------------------------------
async def test_free_text_question_answered_from_guidelines(engine: Engine) -> None:
r = await say(engine, "помогает ли психотерапия при ПТСР?")
text = joined(r)
assert "В клинических рекомендациях Минздрава сказано:" in text
assert "Источник: КР ПТСР 2023" in text
@pytest.mark.parametrize("message", ["сегодня обычный день", "как дела?"])
async def test_free_text_without_answer_keeps_support(engine: Engine, message: str) -> None:
r = await say(engine, message)
assert joined(r) == SUPPORT
assert "Источник" not in joined(r)
async def test_drug_question_stays_with_instruction(engine: Engine) -> None:
# SPEC §7 п.5: the patient's own drug is answered only from its instruction, never from the guidelines.
r = await say(engine, "через сколько подействуют таблетки?")
text = joined(r)
assert text.startswith(load_med_qa_texts().intro)
assert "клинических рекомендациях" not in text
async def test_side_effect_question_goes_to_checkin(engine: Engine) -> None:
r = await say(engine, "меня тошнит от таблеток, это нормально?")
assert "клинических рекомендациях" not in joined(r)
async def test_acute_risk_never_reaches_knowledge_base(engine: Engine) -> None:
r = await say(engine, "зачем жить? хочу умереть")
assert any(x.kind == "crisis" for x in r)
assert "клинических рекомендациях" not in joined(r)
+323
View File
@@ -0,0 +1,323 @@
"""Demo adapters (#14): МИС mock, connect-code identity, ГРЛС excerpts, doctor notifier, registry setup."""
import json
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import pytest
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription as PrescriptionRow
from app.integrations import (
CompositeNotifier,
ConnectCodeIdentity,
GrlsExcerpts,
MisMock,
NotificationFeed,
setup,
)
from app.integrations import notify as notify_module
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
MemoryOutbox,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.grls import normalize_inn
from app.integrations.notify import TelegramDoctorChat, format_message
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import DOSING_SENTENCE, SECTIONS, med_qa
from app.llm.mock import MockTasks
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
T0 = datetime(2026, 10, 1, 9, 0, tzinfo=UTC)
GRLS_DIR = Path(__file__).resolve().parents[1] / "data" / "meds" / "grls"
CORE_DRUGS = ("сертралин", "пароксетин", "эсциталопрам", "флуоксетин")
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
try:
yield
finally:
await session.configure()
# ----------------------------------------------------------------------------------------------- setup
def test_setup_registers_demo_adapters() -> None:
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.identity, ConnectCodeIdentity)
assert isinstance(integrations.drug_info, GrlsExcerpts)
assert isinstance(integrations.notifier, CompositeNotifier)
# The outbox stays the in-memory queue the web console drains (Telegram wraps it at start).
assert isinstance(integrations.outbox, MemoryOutbox)
def test_setup_is_idempotent() -> None:
before = (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier)
setup()
setup()
assert (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) == before
def test_setup_replaces_only_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
fake_notifier = MemoryNotifier()
sink = type("Sink", (MemoryNotifier,), {})()
monkeypatch.setattr(integrations, "prescriptions", EmptyPrescriptions())
monkeypatch.setattr(integrations, "identity", RejectIdentity())
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
monkeypatch.setattr(integrations, "notifier", fake_notifier)
setup(force=True)
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.notifier, CompositeNotifier)
monkeypatch.setattr(integrations, "notifier", sink)
monkeypatch.setattr("app.integrations._done", False)
setup()
assert integrations.notifier is sink # a swapped-in test sink is kept
# --------------------------------------------------------------------------------------------- mis_mock
async def test_mis_mock_catalogue_fallback() -> None:
mis = MisMock(use_db=False)
[rx] = await mis.get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.schedule["times"]) == ("сертралин", "50 мг", ["08:00"])
assert rx.instructions == "утром после еды"
assert await mis.get_prescriptions("unknown") == []
async def test_mis_mock_catalogue_drugs_have_excerpts() -> None:
raw = json.loads((GRLS_DIR.parent / "mis_prescriptions.json").read_text(encoding="utf-8"))
known = set(GrlsExcerpts().known())
assert {rx["drug"] for p in raw["patients"] for rx in p["prescriptions"]} <= known
async def test_mis_mock_missing_catalogue_is_empty(tmp_path: Path) -> None:
assert await MisMock(tmp_path / "nope.json", use_db=False).get_prescriptions("tok-andrey") == []
async def test_mis_mock_prefers_db(dbs: None) -> None:
async with session.get_clinical_session() as s:
s.add(
PrescriptionRow(
token="tok-andrey",
drug="эсциталопрам",
dose="10 мг",
schedule={"times": ["09:00"], "instructions": "утром"},
start_date=date(2026, 10, 1),
)
)
[rx] = await MisMock().get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.instructions, rx.start_date) == ("эсциталопрам", "10 мг", "утром", date(2026, 10, 1))
# No DB rows for this token -> catalogue.
[rx] = await MisMock().get_prescriptions("tok-sergey")
assert rx.drug == "пароксетин"
async def test_mis_mock_inside_caller_session_keeps_its_work(dbs: None) -> None:
# The dialog calls the adapter while its own clinical session holds flushed changes.
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-smirnova")
assert await MisMock().get_prescriptions(TOKEN) == []
async with session.get_clinical_session() as s:
assert await repo.get_profile(s, TOKEN) is not None
async def test_mis_mock_db_down_falls_back(tmp_path: Path) -> None:
await session.configure(clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
[rx] = await MisMock().get_prescriptions("tok-andrey")
finally:
await session.configure()
assert rx.drug == "сертралин"
# ------------------------------------------------------------------------------------------ connect code
async def _code(code: str = "DEMO-0001") -> None:
async with session.get_identity_session() as s:
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-smirnova", now=T0)
async def test_connect_code_verifies_once(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
await _code()
ident = ConnectCodeIdentity()
monkeypatch.setattr(clock, "now", lambda: T0)
person = await ident.verify(" DEMO-0001 ")
assert person is not None and (person.token, person.doctor_id) == (TOKEN, "doc-smirnova")
# Same connect flow (Telegram /start, then the engine's `new` state) re-checks the code: still valid.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(minutes=1))
assert await ident.verify("DEMO-0001") == person
# Later the used code is rejected.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(hours=1))
assert await ident.verify("DEMO-0001") is None
async def test_connect_code_unknown_or_empty(dbs: None) -> None:
assert await ConnectCodeIdentity().verify("NOPE") is None
assert await ConnectCodeIdentity().verify(" ") is None
async def test_connect_code_db_down(tmp_path: Path) -> None:
await session.configure(identity_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
assert await ConnectCodeIdentity().verify("DEMO-0001") is None
finally:
await session.configure()
# ------------------------------------------------------------------------------------------------- ГРЛС
async def test_grls_core_drugs_have_all_sections() -> None:
grls = GrlsExcerpts()
for inn in CORE_DRUGS:
sections = await grls.sections(inn)
assert {s.section for s in sections} == set(SECTIONS), inn
assert all(s.inn == inn and s.source.startswith("ГРЛС, инструкция по медицинскому") for s in sections)
assert {"миртазапин", "празозин"} <= set(grls.known())
async def test_grls_texts_are_short_safe_and_dose_free() -> None:
grls = GrlsExcerpts()
for inn in grls.known():
for s in await grls.sections(inn):
sentences = [x for x in s.text.replace("! ", ". ").split(". ") if x]
assert 1 <= len(sentences) <= 4, (inn, s.section)
assert not is_forbidden(s.text), (inn, s.section)
if s.section != "administration":
assert not DOSING_SENTENCE.search(s.text), (inn, s.section)
assert "мг" not in s.text.split(), (inn, s.section)
def test_grls_files_carry_verification_header() -> None:
for path in GRLS_DIR.glob("*.yaml"):
assert "сверить с действующей инструкцией ГРЛС" in path.read_text(encoding="utf-8").splitlines()[0], path
async def test_grls_lookup_normalisation() -> None:
grls = GrlsExcerpts()
assert normalize_inn("Сертралин 50 мг") == "сертралин"
assert await grls.sections("Sertraline") == await grls.sections("сертралин 50 мг")
assert await grls.sections("аспирин") == []
async def test_grls_bad_files_are_skipped(tmp_path: Path) -> None:
(tmp_path / "bad.yaml").write_text("inn: [", encoding="utf-8")
(tmp_path / "wrong.yaml").write_text("inn: x\n", encoding="utf-8")
assert GrlsExcerpts(tmp_path).known() == []
assert await GrlsExcerpts(tmp_path / "missing").sections("сертралин") == []
# --------------------------------------------------------------- med_qa end-to-end on real sertraline data
@pytest.fixture
def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
async def test_med_qa_day2_nausea(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("подташнивает с утра"))
assert answer is not None and answer.section == "side_effects"
assert "тошнот" in answer.text.lower() and "1–2 недель" in answer.text
assert "Побочное действие" in answer.source
async def test_med_qa_day5_quit(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("от таблеток только хуже, брошу"), topic="quit_intent")
assert answer is not None and answer.section == "onset"
assert "2–4 недели" in answer.text
async def test_med_qa_day10_alcohol(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("без пива не могу уснуть"))
assert answer is not None and answer.section == "alcohol"
assert "алкогол" in answer.text.lower() and "сон" in answer.text
async def test_med_qa_other_drugs_answer_core_topics(mock_gateway: None) -> None:
for inn in CORE_DRUGS:
for question in ("тошнит после таблетки", "когда начнёт действовать", "можно ли выпить пива", "хочу бросить"):
answer = await med_qa(inn, ScrubbedText(question))
assert answer is not None, (inn, question)
assert "мг" not in answer.text
async def test_med_qa_dose_question_still_goes_to_doctor(mock_gateway: None) -> None:
assert await med_qa("сертралин", ScrubbedText("можно увеличить дозу?")) is None
# ------------------------------------------------------------------------------------------------ notify
class FakeChat:
def __init__(self, fail: bool = False) -> None:
self.sent: list[tuple[int, str]] = []
self.fail = fail
async def __call__(self, chat_id: int, text: str) -> None:
if self.fail:
raise RuntimeError("telegram down")
self.sent.append((chat_id, text))
async def test_feed_ids_are_monotonic_and_filterable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clock, "now", lambda: T0)
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=None, bot_token=""))
await notifier.send("doc-smirnova", "moderate", "alcohol_3d", TOKEN)
await notifier.send("doc-other", "high", "sleep_lt4_3n", "b" * 32)
await notifier.send("doc-smirnova", "acute", "phq9_item9", TOKEN)
items = notifier.recent()
assert [n.id for n in items] == [1, 2, 3] and items[0].ts == T0
assert [n.text for n in notifier.recent(since_id=1, doctor_id="doc-smirnova")] == ["phq9_item9"]
assert notifier.recent(since_id=3) == []
def test_feed_is_bounded() -> None:
feed = NotificationFeed(size=2)
for i in range(3):
feed.add("d", "info", f"r{i}", None, T0)
assert [n.id for n in feed.recent()] == [2, 3]
async def test_telegram_doctor_chat_message_has_no_pii() -> None:
chat = FakeChat()
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=-100, bot_token="", sender=chat))
await notifier.send("doc-smirnova", "acute", "PHQ-9 п.9 = 2 — кризисный протокол", TOKEN)
[(chat_id, text)] = chat.sent
assert chat_id == -100
assert text == "[КРИТИЧНО] PHQ-9 п.9 = 2 — кризисный протокол\nПациент #aaaaaa"
assert TOKEN not in text
async def test_telegram_failure_is_swallowed() -> None:
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=1, bot_token="", sender=FakeChat(fail=True)))
await notifier.send("doc-smirnova", "high", "sleep_lt4_3n", TOKEN) # does not raise
assert len(notifier.recent()) == 1
def test_telegram_chat_disabled_without_settings() -> None:
assert not TelegramDoctorChat(chat_id=None, bot_token="x").enabled
assert not TelegramDoctorChat(chat_id=5, bot_token="").enabled
assert TelegramDoctorChat(chat_id=5, bot_token="123:abc").enabled
def test_format_message_trims_long_reason() -> None:
text = format_message("info", "x" * 500, None)
assert len(text.splitlines()[0]) <= notify_module.MAX_REASON_CHARS + len("[Инфо] ")
assert text.endswith("Пациент #—")
+171
View File
@@ -0,0 +1,171 @@
import json
import httpx
import pytest
from app.config import Settings
from app.core.privacy import pseudo_id, scrub
from app.core.types import ScrubbedText
from app.integrations.base import Prescription, integrations
from app.integrations.emias_record import emias_records
from app.llm import agent
from app.llm.agent.drug_safety import check_patient, find_drug
from app.llm.agent.providers import MockAgentProvider, OpenRouterProvider
from app.llm.agent.tools import ToolContext, run_tool
ANDREY = pseudo_id("900-000-013 34")
ANNA = pseudo_id("900-000-023 04")
SERGEY = pseudo_id("900-000-037 02")
class _Rx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="50 мг", instructions="утром после еды")]
@pytest.fixture
def sertraline(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("сертралин"))
# ----------------------------------------------------------------------------------------------- drug safety
@pytest.mark.parametrize("name", ["сертралин", "сертралине", "Золофт", "сертралин 50 мг", "sertraline"])
def test_find_drug_forms(name):
assert find_drug(name).inn == "сертралин"
def test_find_drug_unknown():
assert find_drug("аспирин") is None
async def test_andrey_sertraline_findings(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("сертралин"), rec)
kinds = {(f.kind, f.severity) for f in findings}
assert ("condition", "high") in kinds # ulcer history
assert any(f.kind == "interaction" and "ибупрофен" in f.text for f in findings)
async def test_ibuprofen_sees_current_ssri(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("ибупрофен"), rec)
assert any("СИОЗС" in f.text and "сертралин" in f.text for f in findings)
async def test_pregnancy_and_triptan(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("флуоксетин"))
anna = await emias_records.get(ANNA)
assert any(f.kind == "pregnancy" for f in check_patient(find_drug("флуоксетин"), anna))
monkeypatch.setattr(integrations, "prescriptions", _Rx("пароксетин"))
sergey = await emias_records.get(SERGEY)
assert any("Триптаны" in f.text for f in check_patient(find_drug("пароксетин"), sergey))
# ----------------------------------------------------------------------------------------------------- tools
async def test_record_tool_has_no_identifiers(sertraline):
out = await run_tool(ToolContext(token=ANDREY), "get_patient_record", {})
data = json.loads(out)
assert data["found"] and data["current_prescriptions"][0]["drug"] == "сертралин"
assert "900-000" not in out and "Андрей" not in out
async def test_search_tool_drops_doses():
out = json.loads(await run_tool(ToolContext(token=ANDREY), "search_guidelines", {"query": "дозы СИОЗС при ПТСР"}))
for r in out["results"]:
assert " мг" not in r["text"]
async def test_unknown_tool_is_json_error():
assert "error" in json.loads(await run_tool(ToolContext(token=ANDREY), "rm_rf", {}))
# ------------------------------------------------------------------------------------------------ agent loop
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "chat_agent": "openrouter", "chat_agent_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake_openrouter(final_text: str, calls: list[dict]) -> tuple[OpenRouterProvider, list[dict]]:
sent: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
sent.append(body)
if len(sent) == 1:
msg = {"role": "assistant", "content": None, "tool_calls": calls}
else:
msg = {"role": "assistant", "content": final_text}
return httpx.Response(200, json={"choices": [{"message": msg}], "usage": {"total_tokens": 10}})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return OpenRouterProvider(_settings(), client=client), sent
_CALLS = [
{"id": "a", "type": "function", "function": {"name": "get_patient_record", "arguments": "{}"}},
{
"id": "b",
"type": "function",
"function": {"name": "check_drug_for_patient", "arguments": '{"drug": "ибупрофен"}'},
},
]
async def test_agent_openrouter_tool_loop(sertraline):
text = (
"В вашей карте отмечена язвенная болезнь, а ибупрофен с сертралином повышает риск кровотечений. "
"Обсудите с врачом."
)
provider, sent = _fake_openrouter(text, _CALLS)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен? Мой телефон 89161234567"), provider=provider, settings=_settings()
)
assert ans is not None and ans.text == text
assert ans.tools == ["get_patient_record", "check_drug_for_patient"]
assert any("ЕМИАС" in s for s in ans.sources)
first, second = sent
assert "89161234567" not in json.dumps(first, ensure_ascii=False) # scrubbed before the provider
tool_msgs = [m for m in second["messages"] if m["role"] == "tool"]
assert len(tool_msgs) == 2 and all("name" not in m for m in tool_msgs)
assert first["tools"] and first["model"]
async def test_forbidden_output_rejected(sertraline):
provider, _ = _fake_openrouter("Увеличьте дозу сертралина вдвое.", _CALLS)
assert await agent.answer_chat(ANDREY, scrub("что делать"), provider=provider, settings=_settings()) is None
async def test_provider_error_returns_none():
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500)))
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unscrubbed_input_never_sent():
provider, sent = _fake_openrouter("ok", [])
assert await agent.answer_chat(ANDREY, ScrubbedText("звоните 8 916 123-45-67"), provider=provider) is None
assert sent == []
async def test_mock_agent_answers_offline(sertraline):
ans = await agent.answer_chat(
ANDREY, scrub("Можно ли мне ибупрофен вместе с моими таблетками?"), provider=MockAgentProvider()
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "mock"
def test_build_provider_modes():
assert agent.build_provider(Settings(_env_file=None, chat_agent="auto")) is None
assert isinstance(agent.build_provider(_settings(chat_agent="auto")), OpenRouterProvider)
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None
+4 -3
View File
@@ -3,7 +3,7 @@
import pytest import pytest
from app.core.types import ScrubbedText from app.core.types import ScrubbedText
from app.integrations.base import DrugSection from app.integrations.base import DrugSection, NoDrugInfo, integrations
from app.llm import med_qa as med_qa_module from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt
@@ -173,8 +173,9 @@ async def test_unknown_drug_and_unknown_topic(info: FakeDrugInfo, mock_gateway:
assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None
async def test_default_registry_has_no_data(mock_gateway: None) -> None: async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None:
# Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing. # Without excerpts (base.py default NoDrugInfo; #14 registers real ones at import) nothing is answered.
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
assert await med_qa("сертралин", S("тошнит")) is None assert await med_qa("сертралин", S("тошнит")) is None
+344
View File
@@ -0,0 +1,344 @@
"""RAG knowledge base (#60): corpus integrity, retrieval quality, grounded and safe answers."""
import re
import pytest
from app.core.types import ScrubbedText
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.llm.rag import qa as answer_module
from app.llm.rag.corpus import heading, load_chunks, load_config, rag_dir, split_sentences
from app.llm.rag.index import get_index, tokens
from app.llm.rag.qa import LIT_REFS, PRESCRIBING, QUOTE_FILTER, answer, fallback_text, select
from app.llm.rag.stemmer import stem
S = ScrubbedText
# question -> (document, pattern that must occur in one of the top-3 chunks). Answers checked by hand
# against the PDFs; the set doubles as the tuning set, so the threshold below guards against regressions.
GOLD = [
("через сколько начинают действовать антидепрессанты", "kr_depression", r"Тимоаналептический эффект обычно"),
("когда антидепрессант даст эффект", "kr_depression", r"Тимоаналептический эффект обычно"),
("помогает ли ДПДГ при ПТСР", "kr_ptsd", r"ДПДГ"),
("какая психотерапия эффективна при посттравматическом расстройстве", "kr_ptsd", r"наибольшей доказательной"),
("как правильно отменять антидепрессанты", "kr_depression", r"постепенное снижение дозы"),
("что такое флэшбек", "kr_ptsd", r"Флэшбек \(flashback"),
("шкала Гамильтона для депрессии", "kr_depression", r"Шкала Гамильтона для оценки депрессии"),
("помощь военнослужащим после боевой психической травмы", "kr_ptsd", r"военнослужащ"),
("колумбийская шкала суицидального риска", "kr_depression", r"Колумбийская шкала"),
("вызывают ли бензодиазепины зависимость", "kr_ptsd", r"формировать зависимость"),
("что сказать пациенту о ПТСР", "kr_ptsd", r"Что такое посттравматическое"),
("электросудорожная терапия при резистентной депрессии", "kr_depression", r"лектросудорожн"),
("скрининговый опросник ПТСР", "kr_ptsd", r"Trauma Screening"),
("физическая активность при депрессии", "kr_depression", r"физическ"),
("сертралин при ПТСР", "kr_ptsd", r"ертралин"),
("противорецидивная терапия депрессии сколько длится", "kr_depression", r"ротиворецидивн"),
("правило M7 срочные симптомы", "meds_module_spec", r"M7"),
("через сколько оценивают ответ на лечение", "meds_module_spec", r"4–6 неделе"),
]
GOLD_MIN_HITS = 15
OFF_TOPIC = [
"какой рецепт борща",
"как починить машину",
"расписание электричек",
"кто выиграл чемпионат по футболу",
"курс доллара сегодня",
"погода в москве завтра",
"как установить windows",
"лучший фильм года",
"как выучить английский",
]
PATIENT_QUESTIONS = [
"через сколько начинают действовать антидепрессанты",
"что такое ПТСР",
"что такое флэшбек",
"помогает ли психотерапия при ПТСР",
"как правильно отменять антидепрессанты",
"что будет если пить таблетки с алкоголем",
"сертралин при ПТСР",
"вызывают ли бензодиазепины зависимость",
]
@pytest.fixture(autouse=True)
def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(answer_module, "get_gateway", lambda: LLMGateway(MockTasks()))
class FixedPhraseTasks(MockTasks):
"""An LLM that returns a fixed rephrasing."""
name = "fake-llm"
def __init__(self, reply: str) -> None:
self.reply = reply
async def phrase(self, intent: str, payload: ScrubbedText, base_text: str) -> str | None:
return self.reply
def _words(text: str) -> str:
return " ".join(re.findall(r"\w+", LIT_REFS.sub("", text).lower()))
# --- stemmer ------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
("word", "expected"),
[
# reference outputs of the Snowball Russian stemmer
("вагоне", "вагон"),
("важнейшими", "важн"),
("гостиница", "гостиниц"),
("сделавшись", "сдела"),
("посттравматическое", "посттравматическ"),
("антидепрессантов", "антидепрессант"),
("пробуждения", "пробужден"),
("ёлками", "елк"),
("SSRI", "ssri"),
],
)
def test_stemmer(word: str, expected: str) -> None:
assert stem(word) == expected
def test_tokens_drop_stop_words_and_split_hyphens() -> None:
assert tokens("что такое когнитивно-поведенческая терапия") == [
"когнитивн-поведенческ",
"когнитивн",
"поведенческ",
"терап",
]
# --- corpus -------------------------------------------------------------------------------------------
def _page_of(text: str, line: str) -> int | None:
"""Page number of the first line equal to `line`."""
page = None
for ln in text.splitlines():
if m := re.match(r"^--- стр\. (\d+) ---$", ln):
page = int(m.group(1))
elif ln == line:
return page
return None
def test_every_page_is_chunked_except_bibliography() -> None:
chunks = load_chunks()
for doc in load_config().documents:
text = (rag_dir() / doc.file).read_text(encoding="utf-8")
n_pages = len(re.findall(r"^--- стр\. \d+ ---$", text, re.M))
own = [c for c in chunks if c.doc.id == doc.id]
assert own, doc.id
assert all(1 <= c.page_start <= c.page_end <= n_pages for c in own)
missing = set(range(1, n_pages + 1)) - {p for c in own for p in range(c.page_start, c.page_end + 1)}
lit, appendix = _page_of(text, "Список литературы"), _page_of(text, "Приложение А1")
allowed = set(range(lit + 1, appendix)) if lit and appendix else set()
assert missing <= allowed, (doc.id, sorted(missing - allowed))
def test_chunks_are_bounded_and_ids_unique() -> None:
chunks = load_chunks()
assert len({c.id for c in chunks}) == len(chunks)
max_chars = load_config().chunking.max_chars
# only a single oversized table row may exceed the limit; prose is split by sentences
for c in chunks:
if len(c.text) > 2 * max_chars:
assert any(len(line) > max_chars for line in c.text.splitlines() if line.startswith("|")), c.id
def test_bibliography_and_abbreviations_not_indexed() -> None:
sections = {c.section for c in load_chunks()}
assert not any(s.startswith(("Список литературы", "Список сокращений")) for s in sections)
assert "3.2. Психофармакотерапия" in sections
assert "3.6 Противорецидивная (профилактическая) терапия" in sections
def test_table_pieces_repeat_header() -> None:
for c in load_chunks():
lines = c.text.splitlines()
for i, line in enumerate(lines):
if line.startswith("|") and (i == 0 or not lines[i - 1].startswith("|")):
assert i + 1 < len(lines) and re.match(r"^\|(-+\|)+$", lines[i + 1]), c.id
def test_hamd_items_not_duplicated_or_split() -> None:
"""Converter regression: item 1 of HAM-D was repeated as a fake header on every page, items 2/17 split."""
text = (rag_dir() / "corpus" / "kr_depression.txt").read_text(encoding="utf-8")
assert text.count("| 1. СНИЖЕННОЕ НАСТРОЕНИЕ") == 1
assert re.search(r"^\| 2\. ЧУВСТВО ВИНЫ 0 □", text, re.M)
assert re.search(r"^\| 17\. КРИТИКА 0 □", text, re.M)
@pytest.mark.parametrize(
("line", "is_heading"),
[
("2.1 Жалобы и анамнез", True),
("3.1.2. Купирующая терапия ДЭ средней степени", True),
("3. Лечение, включая медикаментозную и немедикаментозную терапии", True),
("Приложение Г5", True),
("2. Стойкие симптомы повышения психологической чувствительности", False), # list item
("28.07.2022 N 69435).", False), # date
("| 2.1 | таблица |", False),
],
)
def test_heading(line: str, is_heading: bool) -> None:
assert (heading(line) is not None) == is_heading
def test_split_sentences_keeps_abbreviations() -> None:
assert split_sentences("Алгоритм ведения (см. Приложение Б5). Далее текст.") == [
"Алгоритм ведения (см. Приложение Б5).",
"Далее текст.",
]
# --- retrieval ----------------------------------------------------------------------------------------
def test_gold_questions_hit_at_3() -> None:
index = get_index()
misses = [
q
for q, doc, pattern in GOLD
if not any(
h.chunk.doc.id == doc and re.search(pattern, h.chunk.text) for h in index.search(q, top_k=3, min_score=0)
)
]
assert len(GOLD) - len(misses) >= GOLD_MIN_HITS, misses
def test_synonyms_expand_abbreviations() -> None:
terms = get_index().query_terms("ПТСР")
assert terms["птср"] == 1.0
assert 0 < terms["посттравматическ"] < 1.0
def test_team_documents_never_reach_patients_or_doctors() -> None:
for audience in ("patient", "doctor"):
hits = get_index().search("правила M1–M10 модуль лекарства", audience=audience, min_score=0, top_k=20)
assert all("team" not in h.chunk.doc.audiences for h in hits)
team = get_index().search("правило M7 срочные симптомы", audience="team")
assert team and team[0].chunk.doc.id == "meds_module_spec"
def test_patient_information_appendix_boosted() -> None:
hit = get_index().search("что такое ПТСР", audience="patient")[0]
assert hit.chunk.section.startswith("Приложение В")
# --- answers ------------------------------------------------------------------------------------------
@pytest.mark.parametrize("question", OFF_TOPIC)
async def test_off_topic_has_no_answer(question: str) -> None:
assert await answer(S(question), audience="patient") is None
assert await answer(S(question), audience="doctor") is None
@pytest.mark.parametrize("question", ["можно увеличить дозу сертралина?", "можно пить полтаблетки?"])
async def test_dose_question_redirected(question: str) -> None:
assert await answer(S(question), audience="patient") is None
assert "врач" in fallback_text("patient")
@pytest.mark.parametrize("question", PATIENT_QUESTIONS)
async def test_patient_answer_is_extractive_and_safe(question: str) -> None:
reply = await answer(S(question), audience="patient")
if reply is None: # "ask your doctor" is always a valid outcome
return
sources = {c.id: _words(c.text) for c in load_chunks()}
for sentence in split_sentences(reply.excerpt):
assert any(_words(sentence) in text for text in sources.values()), sentence # quoted, not generated
assert not PRESCRIBING.search(reply.excerpt)
assert not any(p.search(reply.excerpt) for p in QUOTE_FILTER)
assert "Источник:" in reply.text and "стр." in reply.text
assert reply.text.rstrip().endswith(answer_module.load_rag_texts()["patient"].doctor_note)
assert all(c.doc != "meds_module_spec" for c in reply.citations)
async def test_onset_answer_content() -> None:
reply = await answer(S("через сколько начинают действовать антидепрессанты"), audience="patient")
assert reply is not None
assert re.search(r"недел", reply.excerpt)
assert reply.coverage >= load_config().answer.min_coverage
async def test_definition_question_prefers_definition() -> None:
reply = await answer(S("что такое флэшбек"), audience="patient")
assert reply is not None and reply.excerpt.startswith("Флэшбек (flashback")
assert reply.citations[0].pages == "2"
async def test_doctor_answer_verbatim_with_citations() -> None:
reply = await answer(S("синдром отмены антидепрессантов"), audience="doctor")
assert reply is not None and not reply.phrased_by_llm
assert re.search(r"\[КР Депрессия 2024, стр\. \d+(–\d+)?\]", reply.text)
assert "СОПА" in reply.excerpt
async def test_doctor_sees_dosing_patient_does_not() -> None:
q = S("сертралин при ПТСР")
doctor = await answer(q, audience="doctor")
assert doctor is not None and re.search(r"сертралин\*\* 25-200 мг", doctor.excerpt)
patient = await answer(q, audience="patient")
assert patient is None or "мг" not in patient.excerpt
async def test_llm_rephrasing_that_adds_numbers_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(answer_module, "get_gateway", lambda: LLMGateway(FixedPhraseTasks("Эффект будет через 2 дня.")))
reply = await answer(S("через сколько начинают действовать антидепрессанты"), audience="patient")
assert reply is not None and not reply.phrased_by_llm
assert "2 дня" not in reply.text
async def test_llm_rephrasing_kept_when_faithful(monkeypatch: pytest.MonkeyPatch) -> None:
q = "помогает ли психотерапия при ПТСР"
excerpt = " ".join(p.text for p in select(get_index(), q, "patient"))
assert "Это помогает" in excerpt # anaphora pulled in the sentence it refers to
simplified = excerpt.replace("Одновременно с этим, психотерапия", "Кроме того, психотерапия")
monkeypatch.setattr(answer_module, "get_gateway", lambda: LLMGateway(FixedPhraseTasks(simplified)))
reply = await answer(S(q), audience="patient")
assert reply is not None and reply.phrased_by_llm
assert simplified in reply.text and reply.excerpt == excerpt # the verbatim quote is kept for audit
# --- regressions from the post-merge audit (#62) -------------------------------------------------------
def test_paragraph_flowing_to_next_page_cites_both_pages() -> None:
"""The converter glues a paragraph across a page break and marks the join with ⟦стр. N⟧."""
chunks = load_chunks()
assert not any("⟦" in c.text for c in chunks) # markers never reach the text
# KR PTSD: the comment on antipsychotics starts on p.22 and continues on p.23
chunk = next(c for c in chunks if "Препараты могут применяться в комбинированной терапии" in c.text)
assert chunk.page_start <= 23 <= chunk.page_end
async def test_scrub_placeholders_do_not_break_answers() -> None:
plain = await answer(S("что такое ПТСР"), audience="patient")
scrubbed = await answer(S("[NAME], [PHONE], что такое ПТСР"), audience="patient")
assert plain is not None and scrubbed is not None
assert scrubbed.excerpt == plain.excerpt
assert tokens("[NAME] сон") == ["сон"]
def test_question_naming_a_disease_prefers_its_guideline() -> None:
index = get_index()
assert index.cued_docs("ТМС при депрессии") == {"kr_depression"}
assert index.cued_docs("кошмары после войны") == {"kr_ptsd"}
assert index.cued_docs("депрессия и ПТСР") == set() # both named: no preference
assert index.cued_docs("когда подействуют антидепрессанты") == set() # a drug class, not the disease
hit = index.search("транскраниальная магнитная стимуляция при депрессии", audience="patient")[0]
assert hit.chunk.doc.id == "kr_depression"
async def test_answer_follows_disease_cue() -> None:
reply = await answer(S("транскраниальная магнитная стимуляция при депрессии"), audience="patient")
assert reply is not None and reply.citations[0].doc == "kr_depression"
+333
View File
@@ -0,0 +1,333 @@
"""Demo seed (#18): idempotent cabinet, Andrey's modes, colours of the 7 patients, and the console scenario
`data/synthetic/andrey.jsonl` replayed against the real dialog engine + demo scheduler + monitoring (sqlite)."""
import re
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from typing import Any
import httpx
import pytest
from sqlalchemy import func, select
from app.config import Settings
from app.core import clock
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.clinical import ClinicalBase, Dose, SafetyPlan
from app.db.identity import ConnectCode, IdentityBase
from app.dialog import contracts as dialog_contracts
from app.dialog.contracts import Button, Event, Reply
from app.dialog.engine import Engine
from app.integrations.base import MemoryOutbox, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.scheduler import jobs
from app.services import monitoring as _monitoring # noqa: F401 (registers the real MonitoringService)
from scripts import seed_demo
from scripts.seed_demo import ANDREY_COLORS, ANDREY_DAYS, OTHERS, SeedResult, seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0) # virtual "now" of the seed (Europe/Moscow)
@pytest.fixture
async def dbs(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
yield
clock.reset()
await session.configure()
async def counts() -> dict[str, int]:
out: dict[str, int] = {}
for base, factory in ((ClinicalBase, session.get_clinical_session), (IdentityBase, session.get_identity_session)):
async with factory() as s:
for table in base.metadata.sorted_tables:
out[table.name] = await s.scalar(select(func.count()).select_from(table)) or 0
return out
async def open_codes(token: str, *, acked: bool = False) -> set[str]:
async with session.get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token)
return {a.code for a in alerts if (a.acked_at is not None) == acked}
async def color(token: str) -> str | None:
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
return profile.status_color if profile else None
# ----------------------------------------------------------------------------------------------- seed
async def test_seed_is_idempotent(dbs: None) -> None:
await seed(12)
first = await counts()
await seed(12)
assert await counts() == first
assert first["patient_profiles"] == first["patients"] == 8 and first["doctors"] == 1
await seed(0)
again = await counts()
await seed(0)
assert await counts() == again
async def test_reset_recreates_tables_and_zeroes_clock(dbs: None) -> None:
await seed(5)
clock.advance(days=2)
assert await seed_demo.reset(None) is None
assert clock.offset() == timedelta(0)
assert all(v == 0 for k, v in (await counts()).items() if k != "clock_state")
result = await seed_demo.reset() # what POST /demo/reset calls: reset + Andrey on day 0
assert result is not None and result.colors[seed_demo.ANDREY_NAME] == "green"
assert (await counts())["patient_profiles"] == 8
async def test_andrey_day0_is_ready_for_the_live_demo(dbs: None) -> None:
result = await seed(0)
token = result.andrey_token
assert re.fullmatch(r"[A-HJ-NP-Z2-9]{6}", result.connect_code)
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, token)
doctor = await identity_repo.get_doctor(s, seed_demo.DOCTOR_ID)
code = await s.get(ConnectCode, result.connect_code)
assert patient is not None and patient.consent_at is None and patient.display_name == "Андрей С."
assert doctor is not None and doctor.on_call and doctor.full_name == "Смирнова Е. А."
assert code is not None and code.token == token and code.used_at is None
assert await identity_repo.redeem_connect_code(s, code=result.connect_code, now=clock.now()) == token
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None
assert profile.modules == ["veteran", "depression"] and profile.sex == "m"
assert NOW.year - (profile.birth_year or 0) == 34
assert profile.therapy_start == NOW.date() and profile.next_visit == NOW.date() + timedelta(days=28)
assert await repo.get_dialog_state(s, token) is None # consent and onboarding happen live
assert not await repo.list_checkins(s, token) and not await repo.list_scale_results(s, token)
plan = await s.get(SafetyPlan, token)
assert plan is not None and len(plan.steps) == 6
assert result.colors["Андрей С."] == "green"
@pytest.mark.parametrize("day", [3, 4, 5, 6, 9, 10])
async def test_andrey_colour_follows_the_persona(dbs: None, day: int) -> None:
result = await seed(day)
assert result.colors["Андрей С."] == ANDREY_COLORS[day - 1]
async def test_andrey_day12_yellow(dbs: None) -> None:
result = await seed(12)
token = result.andrey_token
assert result.colors["Андрей С."] == "yellow"
assert await open_codes(token) == {"sleep_lt4_3n", "alcohol_3d"}
assert await open_codes(token, acked=True) == {"side_effect_3d", "quit_intent"} # doctor called on day 6
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start == NOW.date() - timedelta(days=12)
rows = await repo.list_checkins(s, token)
assert [(r.mood, r.sleep_bucket, r.alcohol_bucket) for r in rows] == [
(ANDREY_DAYS[n].mood, ANDREY_DAYS[n].sleep, ANDREY_DAYS[n].alcohol) for n in range(1, 13)
]
phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")]
assert phq == [(14, 0, True), (13, 0, True)]
totals = {r.scale: r.total for r in await repo.list_scale_results(s, token) if r.ts.date() < rows[0].day}
assert totals == {"pcptsd5": 4, "pcl5": 46, "phq9": 14, "auditc": 3}
doses = (await s.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
assert [d.status for d in doses] == ["skipped" if n == 5 else "taken" for n in range(1, 13)]
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "idle" and state.data["consent"] is True
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, token)
assert patient is not None and patient.consent_at is not None
async def test_andrey_day13_red_crisis(dbs: None) -> None:
result = await seed(13)
token = result.andrey_token
assert result.colors["Андрей С."] == "red"
async with session.get_clinical_session() as s:
acute = [a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"]
assert acute == ["phq9_item9"]
last = (await repo.list_scale_results(s, token, "phq9"))[-1]
assert (last.total, last.item9, last.scheduled) == (18, 2, False)
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "crisis" and state.data["crisis"]["open"] is True
audits = (await s.scalars(select(AuditLog.action).where(AuditLog.token == token))).all()
assert "crisis_acute" in audits and "ack_alert" in audits
async def test_other_patients_match_the_persona_table(dbs: None) -> None:
result = await seed(0)
expected_codes = {
"Дмитрий В.": {"sleep_lt4_3n"},
"Сергей П.": {"missed_doses_7d"},
"Николай Ф.": {"alcohol_3d"},
"Анна Л.": {"side_effect_3d"},
"Ирина К.": set(),
"Ольга Н.": set(),
"Павел Р.": set(),
}
for spec in OTHERS:
token = result.tokens[spec.name]
assert result.colors[spec.name] == spec.color, spec.name
assert await open_codes(token) == expected_codes[spec.name], spec.name
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start is not None
assert (NOW.date() - profile.therapy_start).days == spec.therapy_day
assert NOW.year - (profile.birth_year or 0) == spec.age
assert profile.next_visit is not None and profile.next_visit > NOW.date()
assert await repo.get_dialog_state(s, token) is None # the scheduler leaves them alone
assert await open_codes(result.tokens["Ольга Н."], acked=True) == {"side_effect_3d"} # «тошнота прошла»
async with session.get_clinical_session() as s:
anna = await repo.get_profile(s, result.tokens["Анна Л."])
assert anna is not None and anna.enhanced_monitoring # age < 25, first weeks of therapy
pavel = await repo.list_scale_results(s, result.tokens["Павел Р."], "phq9")
irina = await repo.list_scale_results(s, result.tokens["Ирина К."], "phq9")
assert pavel[-1].total == 4 and [r.total for r in irina] == [16, 13, 9]
async def test_seed_is_deterministic_except_connect_code(dbs: None) -> None:
async def snapshot(result: SeedResult) -> list[Any]:
async with session.get_clinical_session() as s:
out = []
for token in result.tokens.values():
out += [(c.day, c.mood, c.sleep_bucket, c.alcohol_bucket) for c in await repo.list_checkins(s, token)]
out += [(a.code, a.ts) for a in await repo.list_alerts(s, token=token)]
return out
first = await snapshot(await seed(12))
assert await snapshot(await seed(12)) == first
# ----------------------------------------------------------------------------------------------- scenario file
def test_scenario_file_format_and_in_sync_with_config() -> None:
lines = seed_demo.load_scenario()
assert lines == seed_demo.scenario_lines(), "regenerate: python -m scripts.seed_demo --write-scenario"
assert [line["day"] for line in lines] == list(range(0, 14))
for line in lines:
assert set(line) == {"day", "note", "actions"} and line["note"]
for action in line["actions"]:
assert len(action) == 1 and set(action) <= seed_demo.ALLOWED_ACTIONS, action
((key, value),) = action.items()
assert isinstance(value, str) and value
if key == "advance_to":
assert re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value)
if key == "expect_color":
assert value in {"green", "yellow", "red"}
assert lines[13]["actions"][-1] == {"expect_color": "red"}
class Console:
"""What the demo console (#17) does with a scenario line, in-process: the demo clock + scheduler instead of
`/demo/clock/advance`, the engine instead of `/dialog/{token}/events`, the outbox instead of its poll."""
def __init__(self, token: str, engine: Engine, outbox: MemoryOutbox) -> None:
self.token, self.engine, self.outbox = token, engine, outbox
self.batch: list[Reply] = []
async def advance_to(self, hhmm: str) -> None:
now = clock.now()
hh, mm = map(int, hhmm.split(":"))
target = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
clock.set_now(target)
await jobs.run_until_now()
self.batch = self.outbox.drain(self.token)
def resolve(self, label: str) -> Button:
buttons = [b for r in self.batch for row in r.buttons for b in row]
hits = [b for b in buttons if label in b.label]
where = f"{clock.now():%d %H:%M}: {[r.text[:50] for r in self.batch]}"
assert hits, f"no button «{label}» in {[b.label for b in buttons]} at {where}"
# Unambiguous for any console strategy (first substring hit or exact match).
assert hits[0].label == label and len({b.data for b in buttons if b.label == label}) == 1, label
return hits[0]
async def press(self, label: str) -> None:
button = self.resolve(label)
self.batch = await self.engine.handle(self.token, Event(type="button", button=button.data))
async def text(self, text: str) -> None:
self.batch = await self.engine.handle(self.token, Event(type="text", text=text))
async def ack_all(token: str) -> None:
"""The presenter acknowledges Andrey's alerts on the dashboard (day 6 note)."""
async with session.get_clinical_session() as s:
for a in await repo.list_alerts(s, token=token, open_only=True):
await repo.ack_alert(s, alert_id=a.id, doctor_id=seed_demo.DOCTOR_ID, ts=clock.now())
await _monitoring.refresh_status(s, token)
async def test_scenario_replays_through_the_real_dialog(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
gateway = LLMGateway(MockTasks())
engine = Engine(gateway=gateway)
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: gateway)
previous = dialog_contracts._factory
dialog_contracts.register_engine(lambda: engine) # the scheduler delivers through the same engine
try:
result = await seed(0)
token = result.andrey_token
console = Console(token, engine, outbox)
colors: dict[int, str | None] = {}
for line in seed_demo.load_scenario():
day = line["day"]
for action in line["actions"]:
((key, value),) = action.items()
if day == 6 and action == {"advance_to": "20:00"}:
await ack_all(token) # «до вечернего чек-ина подтвердите алерты»
if key == "expect_color":
assert await color(token) == value, f"day {day}"
else:
await getattr(console, key)(value)
colors[day] = await color(token)
assert (clock.today() - NOW.date()).days == day
finally:
dialog_contracts.register_engine(previous)
assert [colors[d] for d in range(1, 14)] == ANDREY_COLORS
assert any(r.kind == "crisis" for r in console.batch) and any(r.kind == "safety_plan" for r in console.batch)
async with session.get_clinical_session() as s:
rows = {(c.day - NOW.date()).days: c for c in await repo.list_checkins(s, token)}
for n, d in ANDREY_DAYS.items():
c = rows[n]
assert (c.mood, c.med_taken, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
d.mood,
d.med_taken,
d.sleep,
d.nightmares,
d.alcohol,
), n
phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")]
assert phq == [(14, 0, True), (13, 0, True), (18, 2, False)]
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "crisis"
# The 7 background patients are not touched by 13 days of the demo clock.
for spec in OTHERS:
assert await color(result.tokens[spec.name]) == spec.color, spec.name
async def test_api_serves_the_seeded_cabinet(dbs: None) -> None:
from app.main import app
result = await seed(13)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
rows = (await c.get(f"/api/v1/doctors/{seed_demo.DOCTOR_ID}/patients")).json()
personas = (await c.get("/api/v1/demo/personas")).json()
card = await c.get(f"/api/v1/patients/{result.andrey_token}", params={"doctor_id": seed_demo.DOCTOR_ID})
assert len(rows) == 8 and rows[0]["display_name"] == "Андрей С." and rows[0]["status_color"] == "red"
assert {r["display_name"]: r["status_color"] for r in rows} == result.colors
assert personas[0]["token"] == result.andrey_token and personas[0]["therapy_day"] == 13
assert personas[0]["scenario"] == "data/synthetic/andrey.jsonl"
assert card.status_code == 200 and len(card.json()["safety_plan"]) == 6