Compare commits

..
Author SHA1 Message Date
Arxip222andClaude Opus 5.5 0106aefc33 Merge origin/dev into feat/rag-clinical-guidelines
CI / ruff · pytest (pull_request) Successful in 2m9s
Conflict in app/api/routes.py: /demo/personas keeps the dev version (demo console, DB patients
with scenarios); the patient app gets its own /patient/demo-personas (synthetic ЕМИАС accounts).
Anonymous sign-in now always creates a doctor-less pseudonymous profile, since the dialog API
of #12 serves known profiles only.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:22:29 +03:00
Arxip222andClaude Opus 5.5 290b0d6151 feat(web): two-stage questionnaires, screening result and diary by result
- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
  PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
  is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
  with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
  3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
  flags on weekly means against the personal norm; scheduled full scales with change
  (remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
  goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).

ADR-012.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:14:09 +03:00
Arxip222andClaude Opus 5.5 6e7c89900b fix(infra): check out shell scripts with LF
On Windows git converted infra/postgres/init.sh to CRLF; Postgres then failed to run it
(/bin/bash^M: bad interpreter), so the identity/clinical databases and roles were never
created and every API call returned 500.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:14:08 +03:00
Eleboba 37f119610d Merge pull request 'test: без живых вызовов LLM в тестах' (#76) from fix/tests-no-live-llm into dev
CI / ruff · pytest (push) Successful in 1m53s
2026-10-04 15:08:23 +03:00
ElebobaandClaude Opus 5.5 05b8bc3e7e test: never call a live LLM from tests, whatever the local .env says
CI / ruff · pytest (pull_request) Successful in 1m50s
With OPENROUTER_API_KEY in a developer's .env the chat agent switched on in dialog tests (real API calls,
spent money, flaky results). An autouse fixture turns the agent off; agent tests pass explicit providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:06:16 +03:00
Eleboba aac560ad71 Merge pull request 'feat(dashboard): минималистичная главная — утверждённый дизайн 02' (#74) from feat/73-home-design into dev
CI / ruff · pytest (push) Successful in 1m58s
Reviewed-on: #74
2026-10-04 15:04:10 +03:00
Eleboba 473965ae76 Merge pull request 'feat(bot): чат-агент на Gemini 2.5 Flash-Lite + доработки по живым прогонам' (#75) from feat/chat-agent-model into dev
CI / ruff · pytest (push) Successful in 1m59s
2026-10-04 15:02:07 +03:00
ElebobaandClaude Opus 5.5 9ec61b9a03 feat(bot): chat agent on Gemini 2.5 Flash-Lite — grounded first turn, current-prescription defaults
CI / ruff · pytest (pull_request) Successful in 2m5s
Live-tested on OpenRouter: Flash-Lite answers in 3–8 s with correct findings at ~$0.10/$0.40 per 1M tokens
(qwen3.7-flash and gpt-oss-120b took 26–32 s). Force a tool call on the first turn, give the model the
current prescription, let check_drug_for_patient default to it and return composition, intolerance check
and general interactions (alcohol); retry once on an empty final message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:59:38 +03:00
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
Arxip222andClaude Opus 5.5 0195468edc docs(product): mockups brief and the design mockups of the patient app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:12:05 +03:00
Arxip222andClaude Opus 5.5 464b474abd feat(web): questionnaires, diary and medication module in the patient app
- Focus screen renders versioned questionnaire items (iq: buttons): section chip, position
  (adaptive forms without "of N"), recall date, pause / help-now actions, optional skip,
  sleep-hours input, 0-10 scale anchors, RUS-AUDIT item 2 standard-drink table.
- Paused / held-for-review questionnaires get their own screen.
- Today: intake and diary tasks, review notice, therapy-day card on days 1/7/14.
- Drug card /meds/:ref from data/meds/meds_reference.json (draft built from the Minzdrav
  guidelines: depression 2024, PTSD 2023) — expected effect, course, side effects, red flags;
  no doses shown. "Report a side effect" always available (med:side_effects:<ref>).
- API: /instruments/catalog, /meds/reference; today gains questionnaire state, reference id,
  indication and therapy day; demo persona seeds the dashboard dose history.
- INTAKE_DEMO=1 (demo clock only) opens the questionnaires on synthetic data without touching
  the clinical release gate in config/intake_runtime.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:53:13 +03:00
Arxip222andClaude Opus 5.5 40c242bb1a feat(web): full desktop layout instead of a stretched phone view
- focus screens: question and context on the left, answers on the right in
  one wide card; consent text and the 0–10 scale in one wide column
- today: dashboard with tasks, this week's doses and quick actions side by
  side; meds / doctor / profile in two-column grids; welcome gets «Как это
  работает» steps
- fix: 0–10 scale no longer overflows narrow containers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:25:17 +03:00
Arxip222andClaude Opus 5.5 f233c1e5ca fix(web): proxy the dev API to 127.0.0.1 (uvicorn listens on IPv4 only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:18:26 +03:00
Arxip222andClaude Opus 5.5 b4b1bc51c8 feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile
- /flow renders the dialog engine's current step as a mock screen (consent,
  age, onboarding and scales with progress, mood 0–10, free text, dose
  reminder, crisis with hotlines and breathing, safety plan), so every
  engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
  emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
  clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
  prescription from the dashboard fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:11:45 +03:00
Arxip222andClaude Opus 5.5 b8a93ce9d4 feat(web): patient web app with ЕМИАС (demo) and anonymous sign-in
A second channel to the same dialog engine as the Telegram bot: consent,
onboarding, scales and check-ins come from app/dialog; the bot is unchanged.

- API: /auth/emias (doctor's one-time connect code via EmiasMock),
  /auth/emias/demo (synthetic personas), /auth/anonymous (random token,
  nothing stored in the identity DB), /crisis/resources from crisis.yaml
- web/: React + TS + Vite, mobile-first, safe areas, hash routing,
  tokens and texts isolated for a Capacitor mobile build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:57:00 +03:00
Arxip222andClaude Opus 5.5 4d331e4c05 docs(rag): describe RAG approach for Minzdrav clinical guidelines
Structure-aware units with audience/evidence metadata, manual review,
hybrid BM25 search, citation-checked answers and an eval set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 07:57:00 +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
ElebobaandClaude Opus 5.5 251f946691 feat(backend): demo scheduler on the virtual clock — doses, missed, check-ins, scales, silence
CI / ruff · pytest (pull_request) Successful in 1m3s
- app/scheduler/demo.py: run_until(now) replays due and fresh events in time order: dose rows +
  med_reminder, missed after 2 h without an answer → monitoring, daily_checkin, scale_due by the
  config/modules.yaml schedules, daily monitoring pass (silence, missed doses) → reach_out/scale_due
- idempotent via scheduler_events (unique token+key); stale events are not sent; no contact without
  consent or during an open crisis
- config/scheduler.yaml: times and windows; runs while the questionnaire release is off
- POST /demo/clock/advance replays the scheduler up to the new time

Refs #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:25:44 +03:00
Eleboba 8c620419dd Merge pull request 'feat(backend): сервис мониторинга — снимок, правила, алерты, цвет, персистентность часов' (#58) from feat/38-monitoring into dev
CI / ruff · pytest (push) Successful in 1m6s
Reviewed-on: #58
2026-10-04 03:15:56 +03:00
ElebobaandClaude Opus 5.5 4c0836a1be feat(backend): monitoring service — snapshot, rules, alerts, status colour, clock persistence
CI / ruff · pytest (pull_request) Successful in 1m11s
- app/services/snapshot.py: PatientSnapshot from the clinical DB (field mapping, age, therapy day,
  recent C-SSRS risk, weapon access from dialog facts)
- app/services/monitoring.py: RuleMonitoring registered via register_monitoring; persists alert/crisis
  actions, crisis_acute audit, enhanced_monitoring flag, cached status_color, notifies the doctor (and
  the doctor on duty for crises) after commit; a running crisis is not restarted
- demo clock offset persisted in clock_state, restored on scheduler start
- tests: Andrey's 13 days through the service → G G G Y Y G G G G Y Y Y R

Refs #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:13:57 +03:00
Eleboba 20973bef17 Merge pull request 'fix(bot): пробелы движка — повтор C-SSRS, вступления шкал, данные в кнопках, тайм-аут шага' (#57) from fix/56-engine-gaps into dev
CI / ruff · pytest (push) Successful in 1m10s
2026-10-03 19:03:10 +03:00
ElebobaandClaude Opus 5.5 d06720fdab fix(bot): close dialog engine gaps (C-SSRS repeat, scale intros, button payloads, step timeout)
CI / ruff · pytest (pull_request) Successful in 1m19s
- No same-day C-SSRS repeat: a risk screen completed within cssrs_cooldown_hours is not queued again
  after PHQ-9 item 9 >= 1 (scale crisis path, rule schedule_scale, scale_due); crisis script and alerts stay.
- Scale intro registry: register_scale_intro(scale_id, text_or_fn); start_scale(intro=None) uses it.
  veteran_daily migrated from per-scale on_enter hooks.
- Prefix triggers "button:<prefix>:*" with trigger_payload(event); medication buttons carry the dose
  (med:taken:<dose_id>, ...), med_pending dropped; payload-less legacy buttons answer as stale.
- Abandoned-step timeout: settings.abandon_after_hours (default 6 h) returns abandonable states
  (daily.*, meds.*, scale.* not in onboarding) to idle before the next event; never in crisis or with intake on.

Refs #56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:00:47 +03:00
Eleboba 2b7cbb8c2f Merge pull request 'feat(bot): ежедневный чек-ин ветерана, приём лекарств и реакции на правила' (#55) from feat/37-veteran-daily into dev
CI / ruff · pytest (push) Successful in 1m51s
2026-10-03 18:47:52 +03:00
ElebobaandClaude Opus 5.5 2972b330b0 test(bot): Andrey 13-day acceptance scenario and medication flow units
CI / ruff · pytest (pull_request) Successful in 1m3s
Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:45:53 +03:00
ElebobaandClaude Opus 5.5 2b13d54ed7 feat(bot): veteran daily check-in, medication reminders and rule reactions
Daily check-in (mood → dose → sleep/nightmares → alcohol → free text with
extract_checkin, buttons win over the LLM, progressive upsert of
daily_checkins), medication reminder flow (taken / skip with reason /
side effects → doses + daily_checkins), medication question menu via
med_qa, med_info rule reactions from the drug instruction excerpt,
«записаться раньше» request recorded for lane B, friendly intros for
scheduled scales. actions.py: additive med_info handler hook.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:45:53 +03:00
Eleboba 4c6e150162 Merge pull request 'feat(questionnaire): revised intake, diaries and follow-up monitoring' (#53) from feat/52-questionnaire into dev
CI / ruff · pytest (push) Successful in 59s
Reviewed-on: #53
2026-10-03 18:41:31 +03:00
Eleboba de5b1e05fb Merge pull request 'feat(bot): онбординг ветерана — скрининг PC-PTSD-5 → PCL-5, PHQ-9, AUDIT-C, вопрос об оружии' (#54) from feat/36-veteran-onboarding into dev
CI / ruff · pytest (push) Successful in 36s
2026-10-03 18:39:39 +03:00
ElebobaandClaude Opus 5.5 0a92266bab feat(bot): veteran onboarding — exposure, PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C, weapon access
CI / ruff · pytest (pull_request) Successful in 32s
Start screening of the veteran module: peer-tone intro, trauma exposure gate for
PC-PTSD-5, PCL-5/PHQ-9/AUDIT-C via scale sessions (sex asked if unknown), weapon
access question stored as facts for lane B with a safe-storage block on risk,
plain-language result, modules enabled in profile, FAQ, resume after a crisis.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:37:46 +03:00
Pavel Eliseev 0bd07045cd docs(clinical): attach visual PR implementation report
CI / ruff · pytest (pull_request) Successful in 56s
Refs #52
2026-10-03 18:33:57 +03:00
Pavel Eliseev c42ef621e5 feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable.

Refs #52
2026-10-03 18:19:34 +03:00
Pavel Eliseev 8b61eecf1b docs(clinical): simplify translation review annotations
CI / ruff · pytest (pull_request) Successful in 30s
Refs #52
2026-10-03 17:41:01 +03:00
Pavel Eliseev fe5b6fe103 docs(clinical): review revised specification and backtranslate PCL-5
CI / ruff · pytest (pull_request) Successful in 38s
Refs #52
2026-10-03 17:38:54 +03:00
Pavel Eliseev 57b5ad1eb2 feat(core): add verified Russian C-SSRS Recent source content
CI / ruff · pytest (pull_request) Successful in 31s
2026-10-03 17:24:48 +03:00
Pavel Eliseev da089d67cb docs(clinical): normalize review table serialization
CI / ruff · pytest (pull_request) Successful in 30s
Refs #52
2026-10-03 16:16:01 +03:00
Pavel Eliseev 3cd7e2fd75 feat(core): add review-gated intake questionnaire rules
Correct RUS-AUDIT-S composition, preserve C-SSRS lifetime behavior, and prepare clinical review materials.

Refs #52
2026-10-03 16:13:36 +03:00
Eleboba f6e4828f01 Merge pull request 'feat(bot): Telegram-адаптер поверх движка диалога' (#51) from feat/11-telegram into dev
CI / ruff · pytest (push) Successful in 31s
2026-10-03 15:45:34 +03:00
Eleboba e884d896ff Merge pull request 'feat(bot): движок диалога — scrub → safety → FSM → мониторинг, согласие, возраст, кризис' (#50) from feat/10-dialog-engine into dev
CI / ruff · pytest (push) Successful in 26s
2026-10-03 15:44:11 +03:00
ElebobaandClaude Opus 5.5 fa40be3d94 feat(bot): dialog engine — scrub → safety → FSM → monitoring, consent, age gate, crisis
CI / ruff · pytest (pull_request) Successful in 25s
- Engine.handle registered via register_engine; lexicon-first safety (no LLM on ACUTE),
  fixed crisis script once per open crisis, POSSIBLE → C-SSRS screen
- FSM registry (@state/@on_enter/@trigger) with auto-loaded flows; core flow:
  new → consent → age_gate → module onboarding → idle, crisis, consent_revoked
- reusable scale session (buttons, extract_item + clarify, item 9 / C-SSRS buttons-only,
  crisis flags), execution of monitoring dialog actions, texts in config/texts/dialog.yaml

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:42:36 +03:00
228 changed files with 40186 additions and 72 deletions
+8
View File
@@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS=
GIGACHAT_SCOPE=GIGACHAT_API_PERS
GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
@@ -22,6 +27,9 @@ DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005)
DEMO_CLOCK=true
# Questionnaires of the intake (#53) on synthetic data, demo only (needs DEMO_CLOCK). The clinical release gate in
# config/intake_runtime.yaml stays closed; without a signed routing policy every finished intake goes to review.
INTAKE_DEMO=false
TZ=Europe/Moscow
# --- Gitea (for Claude Code / team workflow, not used by the app)
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside Linux containers: never check them out with CRLF (bash fails on the carriage return).
*.sh text eol=lf
+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),
)
+6
View File
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
from app.api.schemas import (
AlertOut,
DemoPersona,
DiscontinuationRisk,
DoseOut,
PatientCard,
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
]
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
+406
View File
@@ -0,0 +1,406 @@
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
Two ways in:
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
patient is linked to the attending doctor, who sees the data on the dashboard.
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
The token returned here is the session credential of the prototype. Production needs short-lived sessions
issued by the server instead of handing out the pseudonymous token itself.
"""
import logging
import secrets
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.api import fixtures
from app.api.schemas import DemoPersona
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import intake_enabled, load_runtime
from app.core.interpretation import texts as results_texts
from app.core.meds_reference import find_drug, indication_for, load_reference
from app.core.safety import CrisisResource, crisis_reply
from app.db import repo
from app.db.clinical import DailyCheckin, Dose, Prescription
from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
log = logging.getLogger(__name__)
router = APIRouter(tags=["patient-web"])
_emias = EmiasMock()
class AuthOut(BaseModel):
token: str
mode: Literal["emias", "anonymous"]
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
class EmiasCodeIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
class AnonymousIn(BaseModel):
model_config = ConfigDict(extra="forbid")
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
class EmiasDemoIn(BaseModel):
model_config = ConfigDict(extra="forbid")
persona_token: str = Field(min_length=1, max_length=64)
async def _attach_doctor(token: str, doctor_id: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
async def _seed_demo_persona(token: str) -> None:
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
doctor's card tell the same story.
"""
card = fixtures.card(token)
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
if card is None or item is None:
return
now = clock.now()
today = now.date()
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
await repo.upsert_profile(
s,
token=token,
doctor_id=fixtures.DOCTOR_ID,
modules=list(item.modules),
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
next_visit=today + timedelta(days=15),
)
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
return
for p in card.prescriptions:
rx = Prescription(
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
)
s.add(rx)
await s.flush()
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
for d in card.doses:
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
for t in p.schedule.get("times", []):
hh, mm = (int(x) for x in t.split(":"))
s.add(
Dose(
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
)
)
@router.post("/auth/emias", response_model=AuthOut)
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
person = await _emias.verify(body.code)
if person is None:
log.info("web auth: emias code rejected")
raise HTTPException(401, "code not accepted")
await _attach_doctor(person.token, person.doctor_id)
log.info("web auth: emias tok=%s", person.token[:6])
return AuthOut(token=person.token, mode="emias")
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
async def patient_demo_personas() -> list[DemoPersona]:
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
return fixtures.DEMO_PERSONAS
@router.post("/auth/emias/demo", response_model=AuthOut)
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`)."""
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
if persona is None:
raise HTTPException(404, "persona not found")
await _seed_demo_persona(persona.token)
log.info("web auth: emias demo tok=%s", persona.token[:6])
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
@router.post("/auth/anonymous", response_model=AuthOut)
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
"""
token = secrets.token_hex(TOKEN_LEN // 2)
focus = body.focus if body is not None else None
async with get_clinical_session() as s:
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else [])
log.info("web auth: anonymous tok=%s", token[:6])
return AuthOut(token=token, mode="anonymous")
class DoseItem(BaseModel):
id: int
drug: str
dose: str
time: str # HH:MM, patient's local time
status: Literal["taken", "missed", "skipped", "pending"]
class WeekDay(BaseModel):
day: date
status: Literal["taken", "missed", "pending", "none"]
class PrescriptionItem(BaseModel):
drug: str
dose: str
times: list[str]
week: list[WeekDay] # Mon..Sun of the current week
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
indication: Literal["depression", "ptsd"] | None = None
start_date: date | None = None
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
class Questionnaires(BaseModel):
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
enabled: bool
intake: Literal["none", "active", "paused", "complete", "review"]
diary_done: bool # both parts of today's diary
diary_next: Literal["morning", "evening"] | None = None # the part still open today
class TodayOut(BaseModel):
today: date
now: str # HH:MM on the (demo) clock — the client never uses its own clock
has_doctor: bool
checkin_done: bool
next_visit: date | None
doses: list[DoseItem]
prescriptions: list[PrescriptionItem]
questionnaires: Questionnaires
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
intake = [r for r in records if r.kind == "intake"]
if any(r.status == "complete" for r in intake):
return "complete"
if not intake:
return "none"
last = max(intake, key=lambda r: r.started_at).status
if last in ("active", "paused"):
return last
return "review" if last in ("review_required", "interrupted") else "none"
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
if not statuses:
return "none"
if any(s in ("missed", "skipped") for s in statuses):
return "missed"
return "taken" if all(s == "taken" for s in statuses) else "pending"
@router.get("/me/{token}/today", response_model=TodayOut)
async def my_today(token: str) -> TodayOut:
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
"""
now = clock.now()
today = now.date()
week_start = today - timedelta(days=today.weekday())
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
doses = (
await s.scalars(
select(Dose)
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
.order_by(Dose.planned_at)
)
).all()
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
today_doses: list[DoseItem] = []
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
for d in doses:
local = d.planned_at.astimezone(now.tzinfo)
marks[(d.prescription_id, local.date())].append(d.status)
rx = by_rx.get(d.prescription_id)
if local.date() == today and rx is not None:
today_doses.append(
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
)
week = [week_start + timedelta(days=i) for i in range(7)]
modules = list(profile.modules or []) if profile else []
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
diary_done = not open_parts
def item(p: Prescription) -> PrescriptionItem:
ref = find_drug(p.drug)
return PrescriptionItem(
drug=p.drug,
dose=p.dose,
times=list((p.schedule or {}).get("times", [])),
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
ref_id=ref["id"] if ref else None,
indication=indication_for(modules, ref) if ref else None,
start_date=p.start_date,
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
)
return TodayOut(
today=today,
now=now.strftime("%H:%M"),
has_doctor=profile is not None and bool(profile.doctor_id),
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
next_visit=profile.next_visit if profile else None,
doses=today_doses,
prescriptions=[
item(p)
for p in prescriptions
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
],
questionnaires=Questionnaires(
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
enabled=intake_enabled(),
intake=_intake_state(records),
diary_done=diary_done,
diary_next=open_parts[0] if open_parts else None,
),
)
class ResultFinding(BaseModel):
code: str
title: str
class Conclusion(BaseModel):
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
day: date
title: str
lines: list[str]
findings: list[ResultFinding]
modules: list[str]
disclaimer: str
class HistoryItem(BaseModel):
id: str
kind: str
kind_title: str
status: str
status_title: str
day: date
started_at: datetime
completed_at: datetime | None
title: str | None # interpretation shown to the patient when the form was finished
lines: list[str]
class ResultsOut(BaseModel):
conclusion: Conclusion | None
history: list[HistoryItem]
@router.get("/me/{token}/results", response_model=ResultsOut)
async def my_results(token: str) -> ResultsOut:
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
async with get_clinical_session() as s:
records = list(
(
await s.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
)
).all()
)
t = results_texts()
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
conclusion = None
if intake and intake.details.get("interpretation"):
i = intake.details["interpretation"]
conclusion = Conclusion(
day=intake.day,
title=i["title"],
lines=i["lines"],
findings=[ResultFinding(**f) for f in i.get("findings", [])],
modules=i.get("modules", []),
disclaimer=i.get("disclaimer", t["disclaimer"]),
)
history = [
HistoryItem(
id=r.id,
kind=r.kind,
kind_title=t["kinds"].get(r.kind, r.kind),
status=r.status,
status_title=t["status"].get(r.status, r.status),
day=r.day,
started_at=r.started_at,
completed_at=r.completed_at,
title=(r.details.get("interpretation") or {}).get("title"),
lines=(r.details.get("interpretation") or {}).get("lines", []),
)
for r in records
]
return ResultsOut(conclusion=conclusion, history=history)
@router.get("/crisis/resources", response_model=list[CrisisResource])
async def crisis_resources() -> list[CrisisResource]:
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
return crisis_reply([], "patient").resources
@router.get("/instruments/catalog", response_model=Catalog)
async def instruments_catalog() -> Catalog:
"""Form, position and recall period of every questionnaire item, for the question screen header."""
return catalog()
@router.get("/meds/reference")
async def meds_reference() -> dict[str, Any]:
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
The internal rule parameters stay on the server.
"""
ref = load_reference()
return {k: v for k, v in ref.items() if k != "rules"}
+98
View File
@@ -0,0 +1,98 @@
"""Audited questionnaire review for the synthetic prototype.
Like the existing API, doctor_id is supplied by the caller; production must put
these routes behind authenticated staff identity. No clinical data cross into identity.
"""
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.core import clock
from app.db import repo
from app.db.audit import audit
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.services import questionnaire as service
router = APIRouter(tags=["questionnaire-review"])
class ReviewIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1)
assessment_performed: Literal[True]
decision: Literal["resume", "keep_hold"]
reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$")
async def require_doctor(session, token, doctor_id):
profile = await repo.get_profile(session, token)
if profile is None or profile.doctor_id != doctor_id:
raise HTTPException(404, "patient not found")
@router.get("/patients/{token}/questionnaires")
async def history(token: str, doctor_id: str):
async with get_clinical_session() as session:
await require_doctor(session, token, doctor_id)
rows = await service.records(session, token)
await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now())
return [
{
"id": r.id,
"kind": r.kind,
"status": r.status,
"version": r.version,
"policy_version": r.policy_version,
"started_at": r.started_at,
"completed_at": r.completed_at,
"answers": r.answers,
"details": r.details,
}
for r in rows
]
@router.post("/patients/{token}/questionnaires/{record_id}/review")
async def review(token: str, record_id: str, body: ReviewIn):
now = clock.now()
async with get_clinical_session() as session:
await service.lock_patient(session, token)
await require_doctor(session, token, body.doctor_id)
state = await repo.get_dialog_state(session, token)
row = await session.get(QuestionnaireRecord, record_id)
if row is None or row.token != token:
raise HTTPException(404, "record not found")
if not state or state.data.get("consent") is not True:
raise HTTPException(409, "consent required")
if row.status not in ("review_required", "interrupted"):
raise HTTPException(409, "record does not require review")
if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"):
raise HTTPException(409, "close the acute episode after assessment before resuming routine work")
detail = {
"doctor_id": body.doctor_id,
"decision": body.decision,
"reference": body.reference,
"at": now.isoformat(),
}
row.details = {**row.details, "clinician_review": detail}
if body.decision == "resume":
row.status = "reviewed"
remaining = [
r
for r in await service.records(session, token)
if r.id != row.id and r.status in ("review_required", "interrupted")
]
if not remaining:
state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None}
await audit(
session,
actor=body.doctor_id,
action="review_questionnaire",
token=token,
details={"record_id": row.id, "decision": body.decision, "reference": body.reference},
ts=now,
)
return {"id": row.id, "status": row.status, "review": detail}
+199 -28
View File
@@ -1,12 +1,23 @@
"""API routes `/api/v1` (#34 stubs → #12 implementation).
"""API routes `/api/v1` (#34 contract → #12 implementation).
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately.
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract).
Paths and schemas are the frozen contract (`app/api/schemas.py`); bodies read the identity and clinical DBs.
Read models live in `app/api/doctor_views.py`, demo-console helpers in `app/api/demo.py`.
Every doctor access to patient data is written to `audit_log` (guardrail #6): `view_patient_list`,
`view_patient` (also a denied attempt: `view_patient_denied`), `view_alerts` (only when new alerts are
disclosed — the dashboard polls every 2–3 s), `ack_alert` (in `repo.ack_alert`), `connect_patient`.
`app/api/fixtures.py` is kept for tests and as the demo cabinet reference (doctor id).
"""
from fastapi import APIRouter, HTTPException
import secrets
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from app.api import demo as console
from app.api import doctor_views as views
from app.api import fixtures
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import (
AckIn,
AlertOut,
@@ -22,67 +33,204 @@ from app.api.schemas import (
PatientListItem,
)
from app.core import clock
from app.dialog.contracts import get_engine
from app.integrations.base import MemoryOutbox, integrations
from app.core.privacy import pseudo_id
from app.db import identity_repo, repo
from app.db.audit import audit
from app.db.clinical import Alert, Message, PatientProfile
from app.db.clinical import Prescription as PrescriptionRow
from app.db.identity import Patient
from app.db.session import get_clinical_session, get_identity_session
from app.dialog.contracts import Event, get_engine
from app.integrations.base import integrations
from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router)
router.include_router(patient_web_router)
DEFAULT_CLINIC = "clinic-demo"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
CODE_LEN = 8
async def _patients(tokens: list[str]) -> dict[str, Patient]:
"""Identity rows for display name / age only (never put SNILS or phone into a response)."""
if not tokens:
return {}
async with get_identity_session() as s:
rows = (await s.scalars(select(Patient).where(Patient.token.in_(tokens)))).all()
return {p.token: p for p in rows}
def _name(patients: dict[str, Patient], token: str) -> str:
p = patients.get(token)
return p.display_name if p else "Пациент"
# ------------------------------------------------------------------ doctor dashboard
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
async def list_patients(doctor_id: str) -> list[PatientListItem]:
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score))
now = clock.now()
async with get_clinical_session() as s:
profiles = await repo.patients_of_doctor(s, doctor_id)
data = [await views.load_patient_data(s, p) for p in profiles]
await audit(s, actor=doctor_id, action="view_patient_list", token=None, details={"count": len(data)}, ts=now)
patients = await _patients([p.token for p in profiles])
items = [views.list_item(d, patients.get(d.profile.token), now) for d in data]
return sorted(items, key=views.sort_key)
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
async def patient_card(token: str, doctor_id: str) -> PatientCard:
"""Patient card. Every call is written to audit_log as `view_patient` (#12)."""
card = fixtures.card(token)
if card is None:
raise HTTPException(404, "patient not found")
"""Patient card. Every call is written to audit_log as `view_patient`; only the attending doctor may read it."""
now = clock.now()
patient = (await _patients([token])).get(token)
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
if profile is None:
raise HTTPException(404, "patient not found")
if profile.doctor_id != doctor_id:
await audit(s, actor=doctor_id, action="view_patient_denied", token=token, ts=now)
await s.commit()
raise HTTPException(403, "not your patient")
data = await views.load_patient_data(s, profile)
card = await views.build_card(s, data, patient, now)
await audit(s, actor=doctor_id, action="view_patient", token=token, ts=now)
# Summary after the commit: the (possibly slow) LLM call must not hold the DB transaction.
from app.llm.summary import build_summary # summary imports app.api.schemas; avoid an import cycle
card.summary = await build_summary(card, today=now.date())
return card
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
alerts = [a for a in fixtures.all_alerts() if a.id > since_id]
return [a for a in alerts if a.acked_at is None] if open_only else alerts
async with get_clinical_session() as s:
tokens = list(await s.scalars(select(PatientProfile.token).where(PatientProfile.doctor_id == doctor_id)))
stmt = select(Alert).where(Alert.token.in_(tokens), Alert.id > since_id)
if open_only:
stmt = stmt.where(Alert.acked_at.is_(None))
alerts = (await s.scalars(stmt.order_by(Alert.ts.desc(), Alert.id.desc()))).all()
if alerts:
await audit(
s,
actor=doctor_id,
action="view_alerts",
token=None,
details={"alert_ids": [a.id for a in alerts][:50], "count": len(alerts)},
ts=clock.now(),
)
patients = await _patients(sorted({a.token for a in alerts}))
return [views.alert_out(a, _name(patients, a.token)) for a in alerts]
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None)
if alert is None:
raise HTTPException(404, "alert not found")
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
now = clock.now()
async with get_clinical_session() as s:
alert = await s.get(Alert, alert_id)
if alert is None:
raise HTTPException(404, "alert not found")
profile = await repo.get_profile(s, alert.token)
if profile is None or profile.doctor_id != body.doctor_id:
raise HTTPException(403, "not your patient")
await repo.ack_alert(s, alert_id=alert_id, doctor_id=body.doctor_id, ts=now)
await refresh_status(s, alert.token)
open_acute = [a for a in await repo.list_alerts(s, token=alert.token, open_only=True) if a.level == "acute"]
out = views.alert_out(alert, "")
# The crisis ends only when no acute alert is left open (another acute signal keeps the patient in crisis).
if alert.level == "acute" and not open_acute:
replies = await get_engine().handle(alert.token, Event(type="system", system="doctor_ack"))
if replies:
await integrations.outbox.push(alert.token, replies)
return out.model_copy(update={"patient_name": _name(await _patients([alert.token]), alert.token)})
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001")
now = clock.now()
try:
token = pseudo_id(body.snils)
except ValueError as exc:
raise HTTPException(422, "SNILS must contain 11 digits") from exc
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
async with get_identity_session() as ids:
if await identity_repo.get_patient(ids, token) is not None:
raise HTTPException(409, "patient already connected")
doctor = await identity_repo.get_doctor(ids, body.doctor_id)
await identity_repo.create_patient(
ids,
token=token,
display_name=body.display_name,
snils=body.snils,
clinic_id=doctor.clinic_id if doctor else DEFAULT_CLINIC,
doctor_id=body.doctor_id,
now=now,
birth_year=body.birth_year,
sex=body.sex,
)
await identity_repo.create_connect_code(ids, code=code, token=token, doctor_id=body.doctor_id, now=now)
# Clinical writes nested inside the identity unit of work: if they fail, the identity row rolls back too.
async with get_clinical_session() as s:
await repo.upsert_profile(
s,
token=token,
doctor_id=body.doctor_id,
modules=list(body.modules),
therapy_start=now.date(),
next_visit=body.next_visit,
birth_year=body.birth_year,
sex=body.sex,
)
schedule: dict[str, list[str]] = {"times": list(body.times)}
if body.instructions:
schedule["instructions"] = [body.instructions]
s.add(
PrescriptionRow(token=token, drug=body.drug, dose=body.dose, schedule=schedule, start_date=now.date())
)
await audit(
s,
actor=body.doctor_id,
action="connect_patient",
token=token,
details={"modules": list(body.modules), "drug": body.drug},
ts=now,
)
return ConnectPatientOut(token=token, code=code, deep_link=await console.deep_link(code))
# ------------------------------------------------------------------ patient dialog (web console / any channel)
async def _require_patient(token: str) -> None:
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
await _require_patient(token)
return DialogOut(replies=await get_engine().handle(token, body))
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console."""
box = integrations.outbox
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else [])
box = console.memory_outbox()
return DialogOut(replies=box.drain(token) if box is not None else [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return []
"""Stored dialog (texts are scrubbed before storage, ADR-003), oldest first, the last `limit` messages."""
async with get_clinical_session() as s:
stmt = select(Message).where(Message.token == token).order_by(Message.ts.desc(), Message.id.desc())
rows = (await s.scalars(stmt.limit(max(1, min(limit, 1000))))).all()
return [MessageOut(ts=m.ts, direction=m.direction, text=m.text_scrubbed) for m in reversed(rows)] # type: ignore[arg-type]
# ------------------------------------------------------------------ demo controls (ADR-005)
@@ -97,21 +245,44 @@ async def get_clock() -> ClockOut:
@router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"])
async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
"""Moves the virtual clock; #13 makes the scheduler replay all events up to the new time."""
"""Moves the virtual clock and replays the scheduler up to the new time (#13)."""
clock.advance(days=body.days, hours=body.hours)
await scheduler.run_until_now()
return _clock_out()
@router.post("/demo/reset", tags=["demo"])
async def demo_reset() -> dict[str, str]:
"""Clock back to the wall clock, console outbox emptied, demo data re-seeded by `scripts.seed_demo` (#18)."""
clock.reset()
return {"status": "ok"}
seeded = await console.reset_demo()
return {"status": "ok", "seed": "reset" if seeded else "skipped"}
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]:
return [
DemoPersona(
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
"""Patients with a scripted scenario `data/synthetic/<slug>.jsonl`; the demo cabinet's doctor first."""
scenarios = console.scenario_files()
if not scenarios:
return []
async with get_identity_session() as ids:
patients = (await ids.scalars(select(Patient))).all()
scripted = [(p, scenarios[slug]) for p in patients if (slug := console.persona_slug(p.display_name)) in scenarios]
if not scripted:
return []
today = clock.today()
async with get_clinical_session() as s:
profiles = {p.token: await repo.get_profile(s, p.token) for p, _ in scripted}
out = []
for p, path in sorted(scripted, key=lambda x: (x[0].doctor_id != fixtures.DOCTOR_ID, x[0].display_name)):
profile = profiles.get(p.token)
start = profile.therapy_start if profile else None
out.append(
DemoPersona(
token=p.token,
display_name=p.display_name,
therapy_day=(today - start).days if start else 0,
scenario=path,
)
)
]
return out
+10
View File
@@ -28,6 +28,16 @@ class Settings(BaseSettings):
gigachat_model: str = "GigaChat"
gigachat_verify_ssl: bool = False
# Chat agent (tool-using LLM over RAG + patient record). `auto` = OpenRouter if a key is set, else off
# (extractive RAG / instruction answers stay). OpenRouter is a TEMPORARY prototype provider on synthetic
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1"
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None
+59
View File
@@ -0,0 +1,59 @@
"""Russian Recent source transcription, separate from the legacy sequential scale.
This content does not authorize administering or continuing the questionnaire.
Use questionnaire.score_cssrs_recent for required/missing items and risk, and
evaluate_intake for the clinical policy gate before presenting another question.
Never render interviewer guidance as if it were another patient question.
"""
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
ItemId = Literal["q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"]
class SourceModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class CssrsOption(SourceModel):
value: int = Field(strict=True, ge=0, le=1)
label: str
class CssrsItem(SourceModel):
id: ItemId
text: str = Field(min_length=1)
interviewer_guidance: str | None
recall_period: Literal["past_month", "lifetime", "past_3_months"]
yes_risk: Literal["low", "moderate", "high"]
class CssrsRussianForm(SourceModel):
id: Literal["cssrs_recent_ru"]
version: str = Field(min_length=1)
title: str
subtitle: Literal["Screen Version - Recent"]
period_label_original: str
initial_instruction: str
branch_instruction: str
options: tuple[CssrsOption, CssrsOption]
items: tuple[CssrsItem, ...]
@model_validator(mode="after")
def _structure(self) -> "CssrsRussianForm":
if tuple(item.id for item in self.items) != ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"):
raise ValueError("Recent form requires seven ordered answer fields")
if tuple(option.value for option in self.options) != (1, 0):
raise ValueError("source answer columns are Yes, No")
return self
def load_cssrs_russian(path: Path | None = None) -> CssrsRussianForm:
path = path or get_settings().config_dir / "instruments" / "cssrs_recent_ru.yaml"
return CssrsRussianForm.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+139
View File
@@ -0,0 +1,139 @@
"""Read-only catalog of questionnaire items for patient clients (web app).
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
the versioned sources in config/.
"""
from functools import lru_cache
from typing import Any, Literal
from pydantic import BaseModel
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
class FormInfo(BaseModel):
id: str
title: str
section: Section
recall_period: str | None = None
instruction: str | None = None
class ItemInfo(BaseModel):
form: str
index: int # 1-based position in the form
total: int
adaptive: bool
optional: bool = False
numeric: bool = False
class DrinksTable(BaseModel):
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
title: str
header: list[str]
rows: list[tuple[str, list[str]]]
note: str
class Catalog(BaseModel):
forms: dict[str, FormInfo]
items: dict[str, ItemInfo]
standard_drinks: DrinksTable | None
def _drinks() -> DrinksTable | None:
raw = texts().get("standard_drinks")
if not raw:
return None
lines = [line.strip() for line in raw.splitlines() if line.strip()]
title, header_line, *rest = lines
rows = [line for line in rest if ":" in line]
note = " ".join(line for line in rest if ":" not in line)
return DrinksTable(
title=title,
header=[h.strip() for h in header_line.split("·")],
rows=[
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
],
note=note,
)
@lru_cache(maxsize=1)
def catalog() -> Catalog:
forms: dict[str, FormInfo] = {}
items: dict[str, ItemInfo] = {}
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
forms[form.id] = form
for i, item in enumerate(ids, 1):
items.setdefault(
item,
ItemInfo(
form=form.id,
index=i,
total=len(ids),
adaptive=form.id in ADAPTIVE,
optional=item in optional,
numeric=item in numeric,
),
)
cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
intake_forms: dict[str, Any] = content()["forms"]
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
raw = intake_forms.get(sid) or {}
scale = load_scale(sid)
add(
FormInfo(
id=sid,
title=raw.get("title") or scale.title,
section=section,
recall_period=raw.get("recall_period") or scale.recall_period,
instruction=raw.get("instruction") or scale.instruction,
),
list(scale.item_ids),
)
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
diary = diary_config()
core = [item for part in DIARY_PARTS for item in diary[part]]
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
add(
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
[i["id"] for i in core] + depression + ptsd,
optional={i["id"] for i in core if i.get("optional")},
numeric={i["id"] for i in core if i.get("numeric")},
)
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
scale = load_scale(path.stem)
forms.setdefault(
scale.id,
FormInfo(
id=scale.id,
title=scale.title,
section="scale",
recall_period=scale.recall_period,
instruction=scale.instruction,
),
)
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
+190
View File
@@ -0,0 +1,190 @@
"""Question selection and exact versioned content, with no side effects."""
from dataclasses import dataclass
import yaml
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.questionnaire import IntakePolicy, alcohol_expansion, score_alcohol, score_cssrs_recent
from app.core.scales import ScaleDef, load_scale
@dataclass(frozen=True)
class Question:
id: str
text: str
options: tuple[tuple[object, str], ...] = ()
period: str = "context"
numeric: bool = False
optional: bool = False
maximum: float = 24
def content() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/intake_forms.yaml").read_text())
def texts() -> dict:
return yaml.safe_load((get_settings().config_dir / "texts/intake.yaml").read_text())
def scale_question(scale_id: str, item_id: str) -> Question:
forms = content()["forms"]
scale = ScaleDef.model_validate(forms[scale_id]) if scale_id in forms else load_scale(scale_id)
item = next(i for i in scale.items if i.id == item_id)
instruction = scale.instruction or ""
# The beverage table is part of the approved questionnaire source.
if item_id == "rus_audit_2":
instruction += "\n" + texts()["standard_drinks"]
return Question(
item_id,
instruction + "\n\n" + item.text,
tuple((o.value, o.label) for o in item.options),
scale.recall_period or "",
)
def group_answers(answers: dict, prefix: str) -> dict[str, int]:
return {k: v for k, v in answers.items() if k.startswith(prefix + "_")}
def cssrs_answers(answers: dict) -> dict[str, int]:
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
so a positive answer interrupts the questionnaire wherever it comes."""
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
if "trauma" not in answers:
return Question(
"trauma", content()["trauma_introduction"] + "\n\n" + content()["trauma_filter"], ((1, "Да"), (0, "Нет"))
)
if answers["trauma"]:
for item in load_scale("tsq").item_ids:
if item not in answers:
return scale_question("tsq", item)
if "recent_trauma" not in answers:
return Question("recent_trauma", texts()["recent_trauma"], ((1, "Да"), (0, "Нет")))
for item in load_scale("rus_audit_s").item_ids:
if item not in answers:
return scale_question("rus_audit_s", item)
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"):
if key not in answers:
return Question(key, texts()[key], ((1, "Да"), (0, "Нет")))
if answers["phq9_1"] + answers["phq9_2"] >= policy.phq2_expand_at:
for item in load_scale("phq9").item_ids:
if item not in answers:
return scale_question("phq9", item)
if answers["trauma"] and sum(group_answers(answers, "tsq").values()) >= policy.tsq_expand_at:
for item in load_scale("pcl5").item_ids:
if item not in answers:
return scale_question("pcl5", item)
alcohol = group_answers(answers, "rus_audit")
selected_sex = sex or (answers.get("sex") if answers.get("sex") in ("m", "f") else None)
short = score_alcohol(
{k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}, short=True, sex=selected_sex
)
if alcohol_expansion(short, policy):
for item in load_scale("rus_audit").item_ids:
if item == "rus_audit_2" and alcohol.get("rus_audit_1") == 0:
continue
if item not in answers:
return scale_question("rus_audit", item)
return None
def cssrs_since_contact_question(answers: dict) -> Question | None:
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
"""
c = score_cssrs_recent(cssrs_answers(answers))
missing = [k for k in c.missing if k != "q6_recent"]
if not missing:
return None
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == missing[0])
return Question(
"cssrs_" + item.id,
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
"since_last_contact",
)
DIARY_PARTS = ("morning", "evening")
DIARY_KINDS = ("diary_morning", "diary_evening")
def diary_config() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
so diary answers never mix with the full scales.
"""
if part not in DIARY_PARTS:
raise ValueError("unknown diary part")
config = diary_config()
questions = [
Question(
item["id"],
item["text"],
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_day",
item.get("numeric", False),
item.get("optional", False),
item.get("max", 24),
)
for item in config[part]
]
if part == "evening":
forms = content()["forms"]
if "depression" in modules:
module = config["depression"]
items = forms[module["form"]]["items"]
per_day = module["items_per_day"]
start = (day_index % (len(items) // per_day)) * per_day
options = tuple((o["value"], o["label"]) for o in module["options"])
for item in items[start : start + per_day]:
number = item["id"].rsplit("_", 1)[1]
questions.append(
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
)
if "veteran" in modules:
module = config["ptsd"]
form = forms[module["form"]]
by_id = {i["id"]: i["text"] for i in form["items"]}
options = tuple((o["value"], o["label"]) for o in form["options"])
for item_id in module["items"]:
number = item_id.rsplit("_", 1)[1]
questions.append(
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
)
return tuple(questions)
+205
View File
@@ -0,0 +1,205 @@
"""Versioned release gate and deterministic follow-up planning."""
from calendar import monthrange
from datetime import date, timedelta
from pathlib import Path
from typing import Literal
from zoneinfo import ZoneInfo
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.questionnaire import Approval, demo_mode, load_policy
class RuntimePolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str
enabled: bool = False
approval: Approval | None = None
content_review: Approval | None = None
contact_review: Approval | None = None
timezone: str
morning_hour: int = Field(ge=0, le=23)
evening_hour: int = Field(ge=0, le=23)
norm_days: int = Field(ge=2)
week_days: int = Field(ge=2)
min_norm_values: int = Field(ge=1)
min_week_values: int = Field(ge=1)
mood_drop: float = Field(gt=0, le=10)
depression_item_rise: float = Field(gt=0, le=3)
ptsd_sum_rise: float = Field(gt=0, le=16)
sleep_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24)
alcohol_heavy_portions: float = Field(gt=0)
alcohol_heavy_days: int = Field(ge=1)
adherence_below: float = Field(gt=0, le=1)
missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...]
medication_repeat_days: int = Field(ge=1)
route_intervals: dict[str, dict[str, int]]
alcohol_repeat_days: int = Field(ge=1)
phq9_retriage_delta: int = Field(ge=1)
pcl5_retriage_delta: int = Field(ge=1)
@model_validator(mode="after")
def valid(self):
ZoneInfo(self.timezone)
if set(self.route_intervals) != {"after_crisis", "specialist", "self_help"} or any(
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
):
raise ValueError("all route and scale intervals must be defined")
if self.morning_hour >= self.evening_hour:
raise ValueError("the morning diary must come before the evening one")
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
raise ValueError("medication days must be nonempty and strictly increasing")
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
raise ValueError("intervals must be positive")
if any(
a and a.policy_version != self.version for a in (self.approval, self.content_review, self.contact_review)
):
raise ValueError("review must match the runtime version")
return self
@property
def released(self) -> bool:
return self.enabled and all((self.approval, self.content_review, self.contact_review))
def load_runtime(path: Path | None = None) -> RuntimePolicy:
path = path or get_settings().config_dir / "intake_runtime.yaml"
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
def demo_intake() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
until the approvals are recorded there.
"""
return demo_mode()
def intake_enabled() -> bool:
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
def month_before(day: date, months: int = 1) -> date:
year, month = divmod(day.year * 12 + day.month - 1 - months, 12)
return day.replace(year=year, month=month + 1, day=min(day.day, monthrange(year, month + 1)[1]))
def due_scales(
*,
today: date,
enrolled: date,
last: dict[str, date],
route: Literal["specialist", "self_help"],
medication_changed: date | None = None,
after_crisis: date | None = None,
policy: RuntimePolicy | None = None,
) -> tuple[str, ...]:
"""Return each overdue form once. Therapy day zero is the recorded start/change date.
Due dates stay due until completion. Overlapping schedules choose the earlier
date; missed ticks do not create a burst of historical questionnaires.
"""
p = policy or load_runtime()
due = set()
for scale in ("phq9", "pcl5"):
completed = last.get(scale)
dates = [(completed or enrolled) + timedelta(days=p.route_intervals[route][scale])]
if after_crisis and after_crisis <= today:
dates.append(
max(completed or after_crisis, after_crisis) + timedelta(days=p.route_intervals["after_crisis"][scale])
)
if medication_changed and medication_changed <= today:
for offset in p.medication_days:
milestone = medication_changed + timedelta(days=offset)
if completed is None or completed < milestone:
dates.append(milestone)
break
else:
dates.append(
max(completed or medication_changed, medication_changed + timedelta(days=p.medication_days[-1]))
+ timedelta(days=p.medication_repeat_days)
)
if min(dates) <= today:
due.add(scale)
if (last.get("rus_audit_s") or enrolled) + timedelta(days=p.alcohol_repeat_days) <= today:
due.add("rus_audit_s")
return tuple(sorted(due))
def _mean(values: list[float]) -> float:
return sum(values) / len(values)
def diary_flags(
history: dict[date, dict],
today: date,
policy: RuntimePolicy | None = None,
*,
medication_active: bool = False,
) -> tuple[str, ...]:
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
observation; trends are checked only after it. Missing data never become zero or missed doses.
"""
p = policy or load_runtime()
flags: list[str] = []
current = history.get(today, {})
start = min(history) if history else today
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
week_start = today - timedelta(days=p.week_days - 1)
week = [history[d] for d in sorted(history) if week_start <= d <= today]
trend = (week_start - start).days >= p.norm_days
def values(rows: list[dict], key) -> list[float]:
return [v for r in rows if (v := key(r)) is not None]
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
before, now = values(norm, key), values(week, key)
if not trend or len(before) < min_norm or len(now) < min_week:
return False
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
return change >= by
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
flags.append("phq9_due_mood")
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
for item in range(1, 10):
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
flags.append("phq9_due_depression")
break
# PTSD module: only the sum of the four items is interpreted, never single clusters.
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
def ptsd_sum(r):
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
flags.append("pcl5_due_ptsd")
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all(
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
for r in nights
):
flags.append("sleep_review")
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
flags.append("alcohol_on_medication")
if len(heavy) >= p.alcohol_heavy_days:
flags.append("rus_audit_due_alcohol")
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "no" for r in doses):
flags.append("medication_contact")
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
flags.append("adherence_review")
if current.get("medication_reason") == "side_effects":
flags.append("side_effects_review")
return tuple(flags)
+165
View File
@@ -0,0 +1,165 @@
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
"""
from typing import Any
import yaml
from app.config import get_settings
from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
PHQ9_REMISSION_BELOW = 5
PHQ9_IMPROVED_BY = 5
PCL5_RESPONSE_BY = 10
PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]:
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
def render(interpretation: dict[str, Any]) -> str:
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
parts = [interpretation["title"], *interpretation.get("lines", [])]
if interpretation.get("disclaimer"):
parts.append(interpretation["disclaimer"])
return "\n\n".join(p for p in parts if p)
def _alcohol_zone(totals: dict) -> int | None:
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
return full.get("zone") if full.get("complete") else short.get("zone")
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
"""Screening findings of stage 1, in the order they are shown."""
found = []
phq = totals.get("phq9") or {}
if phq.get("complete"):
if phq["total"] >= policy.phq9_specialist_at:
found.append("depression_suspected")
elif phq["total"] >= policy.phq9_psychologist_at:
found.append("depression_mild")
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
found.append("ptsd_suspected")
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
found.append("ptsd_signs")
zone = _alcohol_zone(totals)
if zone == 4:
found.append("alcohol_high")
elif zone in (2, 3):
found.append("alcohol_risky")
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
found.append("suicide_low")
if answers.get("tbi") == 1:
found.append("tbi")
return found
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
t = texts()
found = intake_findings(totals, answers, policy)
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
head = t["intake"]["attention" if found else "ok"]
lines = [head["text"]]
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
if decision.primary_route in t["routes"]:
lines.append(t["routes"][decision.primary_route])
names = ", ".join(t["modules"]["names"][m] for m in modules)
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
return {
"kind": "intake",
"title": head["title"],
"lines": lines,
"codes": found or ["ok"],
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
"route": decision.primary_route,
"modules": modules,
"disclaimer": t["disclaimer"],
}
def _phq9_level(total: int) -> str:
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
return band["code"]
def scale_interpretation(
kind: str,
totals: dict,
*,
baseline: int | None,
previous: int | None,
policy: IntakePolicy,
retriage_delta: int,
) -> dict:
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
t = texts()["scales"]
if kind == "rus_audit_s":
zone = _alcohol_zone(totals)
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
return {
"kind": kind,
"title": t[kind]["title"],
"lines": [line],
"codes": [f"zone_{zone}" if zone else "unknown"],
}
total = totals[kind]["total"]
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
code = _phq9_level(total) if kind == "phq9" else pcl5_level
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
change = None
if previous is not None and total - previous >= retriage_delta:
change = "worse"
elif baseline is not None:
if kind == "phq9":
if total < PHQ9_REMISSION_BELOW:
change = "remission"
elif baseline > 0 and total <= baseline / 2:
change = "response"
elif baseline - total >= PHQ9_IMPROVED_BY:
change = "improved"
else:
change = "stable"
else:
if baseline - total >= PCL5_RESPONSE_BY:
change = "response"
elif total < PCL5_IMPROVED_BELOW <= baseline:
change = "improved"
else:
change = "stable"
if change:
lines.append(t["change"][change])
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
t = texts()["diary"]
shown = [f for f in flags if f in t["flags"]]
return {
"kind": f"diary_{part}",
"title": t["saved"].format(part=t["parts"][part]),
"lines": [t["flags"][f] for f in shown],
"codes": list(flags),
}
def cssrs_interpretation(risk: str | None) -> dict:
t = texts()["cssrs"]
return {
"kind": "cssrs_since_contact",
"title": t["low" if risk == "low" else "none"],
"lines": [],
"codes": [risk or "none"],
}
+35
View File
@@ -0,0 +1,35 @@
"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py).
Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long
the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the
doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file).
"""
import json
from functools import lru_cache
from typing import Any, Literal
from app.config import get_settings
Indication = Literal["depression", "ptsd"]
@lru_cache(maxsize=1)
def load_reference() -> dict[str, Any]:
path = get_settings().data_dir / "meds" / "meds_reference.json"
return json.loads(path.read_text(encoding="utf-8"))
def find_drug(name: str) -> dict[str, Any] | None:
"""Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered."""
key = name.strip().split()[0].casefold() if name.strip() else ""
for drug in load_reference()["drugs"]:
if key in (drug["id"], drug["name_ru"].casefold()):
return drug
return None
def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None:
"""The indication the patient is followed for, among those the drug has in the guidelines."""
preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"]
return next((i for i in preferred if i in drug["indications"]), None)
+347
View File
@@ -0,0 +1,347 @@
"""Versioned intake rules for clinical review (#52).
No patient wording or LLM calls. This module deliberately does not replace legacy
cssrs_screen (whose q6 only covers three months) or reinterpret historical data.
The dialog adapter must pass RAW answers, persist the policy version, and respect
the returned status. Pending clinical policy never authorizes routine continuation.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.scales import Sex, load_scale, score
Risk = Literal["none", "low", "moderate", "high"]
class Approval(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
reviewer: str = Field(min_length=1)
decision_reference: str = Field(min_length=1)
approved_at: datetime
policy_version: str = Field(min_length=1)
@model_validator(mode="after")
def _aware(self) -> Approval:
if self.approved_at.tzinfo is None:
raise ValueError("approval timestamp must include timezone")
return self
class IntakePolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1)
approval: Approval | None = None
cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending"
rus_audit_s_expand_at: int | None = Field(default=None, ge=1, le=12, strict=True)
rus_audit_s_expand_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
tsq_expand_at: int = Field(ge=1, le=10, strict=True)
phq9_psychologist_at: int = Field(ge=0, le=27, strict=True)
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after")
def _approval_matches(self) -> IntakePolicy:
if self.approval and (
self.approval.policy_version != self.version
or self.cssrs_positive_policy == "pending"
or (self.rus_audit_s_expand_from_zone is None and self.rus_audit_s_expand_at is None)
):
raise ValueError("approval requires the matching version and resolved clinical decisions")
if self.rus_audit_s_expand_from_zone is not None and self.rus_audit_s_expand_at is not None:
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self
def demo_mode() -> bool:
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
real policy and the release gate in config/intake_runtime.yaml are never touched.
"""
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@dataclass(frozen=True)
class AlcoholResult:
scale: str
complete: bool
total: int | None
zone: int | None
missing: tuple[str, ...]
derived: tuple[str, ...] = ()
def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> AlcoholResult:
"""RUS-AUDIT-S uses original items 3/9/10; unknown sex never borrows a cut-off."""
if sex not in (None, "m", "f"):
raise ValueError("sex must be m, f or None")
scale_id = "rus_audit_s" if short else "rus_audit"
# Validate BEFORE derivation: bools, floats and conflicting answers must not be hidden.
score(scale_id, answers, sex=sex)
values = dict(answers)
derived = []
if not short and values.get("rus_audit_1") == 0:
for item in ("rus_audit_2", "rus_audit_3"):
if values.get(item, 0) != 0:
raise ValueError("never drinks conflicts with positive consumption answers")
if item == "rus_audit_2" and item not in values:
values[item] = 0
derived.append(item)
result = score(scale_id, values, sex=sex)
missing = tuple(i for i in load_scale(scale_id).item_ids if i not in values)
zone = int(result.severity_code[-1]) if result.complete and sex is not None else None
return AlcoholResult(
scale_id, result.complete, result.total if result.complete else None, zone, missing, tuple(derived)
)
def extend_alcohol(short_answers: dict[str, int], additional: dict[str, int]) -> dict[str, int]:
"""Reuse original item IDs. Duplicate equal answers count once; conflicting ones fail."""
score("rus_audit_s", short_answers)
score("rus_audit", additional)
for key in short_answers.keys() & additional.keys():
if short_answers[key] != additional[key]:
raise ValueError(f"conflicting repeated answer: {key}")
return {**short_answers, **additional}
def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | None:
"""None means unresolved, never a negative screen or permission to skip the full form."""
if result.scale != "rus_audit_s":
raise ValueError("expansion requires RUS-AUDIT-S")
if not policy.approval or not result.complete or result.zone is None:
return None
if policy.rus_audit_s_expand_at is not None:
return result.total >= policy.rus_audit_s_expand_at
assert policy.rus_audit_s_expand_from_zone is not None
return result.zone >= policy.rus_audit_s_expand_from_zone
_CSSRS_ITEMS = ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent")
@dataclass(frozen=True)
class CssrsResult:
complete: bool
risk: Risk | None
risk_lower_bound: Risk
positive: bool
required: tuple[str, ...]
missing: tuple[str, ...]
conflicts: tuple[str, ...]
def score_cssrs_recent(answers: dict[str, int]) -> CssrsResult:
"""Recent screener structure; q1-5 month, q6 lifetime + conditional three-month follow-up.
These keys describe data, NOT an official Russian translation. Positive signals
survive missing/contradictory answers; only complete consistent data yield final risk.
"""
for key, value in answers.items():
if key not in _CSSRS_ITEMS or type(value) is not int or value not in (0, 1):
raise ValueError("C-SSRS requires known item IDs and integer 0/1 answers")
required = ["q1", "q2"]
if answers.get("q2") == 1:
required.extend(["q3", "q4", "q5"])
required.append("q6_lifetime")
if answers.get("q6_lifetime") == 1:
required.append("q6_recent")
conflicts = []
if answers.get("q2") == 0 and any(answers.get(k) == 1 for k in ("q3", "q4", "q5")):
conflicts.append("ideation_branch_conflict")
if answers.get("q6_lifetime") == 0 and answers.get("q6_recent") == 1:
conflicts.append("behavior_recency_conflict")
missing = tuple(k for k in required if k not in answers)
complete = not missing and not conflicts
lower: Risk = "none"
if any(answers.get(k) == 1 for k in ("q4", "q5", "q6_recent")):
lower = "high"
elif answers.get("q3") == 1 or answers.get("q6_lifetime") == 1:
lower = "moderate"
elif any(answers.get(k) == 1 for k in ("q1", "q2")):
lower = "low"
return CssrsResult(
complete, lower if complete else None, lower, 1 in answers.values(), tuple(required), missing, tuple(conflicts)
)
@dataclass(frozen=True)
class IntakeDecision:
policy_version: str
status: Literal["crisis", "clinical_review_required", "incomplete", "ready"]
primary_route: str | None
parallel_routes: tuple[str, ...]
flags: tuple[str, ...]
missing: tuple[str, ...]
def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy):
"""Preserve independently observable flags even when the primary route is crisis.
Invalid unrelated data is marked for review rather than blocking a known crisis.
The routine branch still validates every answer strictly before routing.
"""
flags = []
try:
score("pcl5", pcl5)
if any(pcl5.get(k, 0) >= policy.pcl5_behavior_flag_at for k in ("pcl5_15", "pcl5_16")):
flags.append("pcl5_behavior")
except ValueError:
flags.append("invalid_pcl5_answers")
try:
full = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
short = score_alcohol(short_values, short=True, sex=sex)
zone = full.zone if full.complete else short.zone
if zone is not None and zone >= 2:
flags.append("alcohol_zone_ii_or_higher")
if psychotropics and (any(v > 0 for v in alcohol.values())):
flags.append("alcohol_psychotropics")
if full.zone == 4:
flags.append("alcohol_zone_iv")
except ValueError:
flags.append("invalid_alcohol_answers")
if traumatic_brain_injury:
flags.append("traumatic_brain_injury")
return flags
def evaluate_intake(
*,
phq9: dict[str, int],
tsq: dict[str, int],
pcl5: dict[str, int],
alcohol: dict[str, int],
cssrs: dict[str, int],
sex: Sex | None,
trauma_exposure: bool | None,
help_now: bool = False,
traumatic_brain_injury: bool = False,
psychotropics: bool = False,
policy: IntakePolicy | None = None,
) -> IntakeDecision:
"""Two-stage candidate routing. No side effects, notifications, or claims of approval.
`alcohol` keeps full-scale IDs across both stages. Existing PHQ/PCL wording and
new C-SSRS content still need release review even with a signed routing policy.
"""
policy = policy or load_policy()
if type(help_now) is not bool or type(traumatic_brain_injury) is not bool or type(psychotropics) is not bool:
raise ValueError("context flags must be bool")
if trauma_exposure is not None and type(trauma_exposure) is not bool:
raise ValueError("trauma_exposure must be bool or None")
# Independent flags survive the higher-priority crisis route.
flags = _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy)
# A manual emergency request does not depend on the completeness of any scale.
if help_now:
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(["help_now", *flags]), ())
# Act on explicit safety answers BEFORE processing unrelated, possibly invalid scales.
phq_item9 = phq9.get("phq9_9")
item9_positive = type(phq_item9) is int and phq_item9 in (1, 2, 3)
try:
c = score_cssrs_recent(cssrs)
except ValueError:
known_high = any(type(cssrs.get(k)) is int and cssrs[k] == 1 for k in ("q4", "q5", "q6_recent"))
if not item9_positive and not known_high:
raise
flags.extend(["invalid_cssrs_answers", "phq9_item9_positive" if item9_positive else "cssrs_positive"])
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), ())
if item9_positive:
flags.append("phq9_item9_positive")
if c.complete and c.risk == "none":
flags.append("phq9_cssrs_discrepancy")
if c.positive:
flags.append("cssrs_positive")
flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and not policy.approval:
return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
phq = score("phq9", phq9)
pc = score("tsq", tsq)
pcl = score("pcl5", pcl5)
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
missing = list(c.missing)
phq2_items = ("phq9_1", "phq9_2")
missing.extend(k for k in phq2_items if k not in phq9)
phq2_complete = all(k in phq9 for k in phq2_items)
phq2_positive = phq2_complete and sum(phq9[k] for k in phq2_items) >= policy.phq2_expand_at
if phq2_positive:
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if trauma_exposure is None:
missing.append("trauma_exposure")
if trauma_exposure is False and tsq:
flags.append("trauma_filter_conflict")
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.tsq_expand_at
if trauma_exposure is True and not pc.complete:
missing.extend(k for k in load_scale("tsq").item_ids if k not in tsq)
if pc_positive and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
# Partially administered optional scales also cannot be treated as completed negatives.
if len(phq9) > 2 and not phq.complete:
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if pcl5 and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
missing.extend(short_alcohol.missing)
if sex is None:
missing.append("alcohol_scoring_sex")
expand = alcohol_expansion(short_alcohol, policy)
if expand or (len(alcohol) > 3 and not full_alcohol.complete):
missing.extend(full_alcohol.missing)
if expand is None:
flags.append("alcohol_transition_unresolved")
if not policy.approval or c.conflicts or "trauma_filter_conflict" in flags:
return IntakeDecision(
policy.version, "clinical_review_required", None, (), tuple(flags), tuple(dict.fromkeys(missing))
)
if missing:
return IntakeDecision(policy.version, "incomplete", None, (), tuple(flags), tuple(dict.fromkeys(missing)))
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0
if c.risk == "moderate":
primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist"
elif phq_total >= policy.phq9_psychologist_at or pc_positive:
primary = "psychologist"
else:
primary = "self_help"
parallel = []
if full_alcohol.zone == 4:
parallel.append("narcologist")
if traumatic_brain_injury:
parallel.append("neurologist")
if c.risk == "low":
flags.append("cssrs_low_safety_plan")
return IntakeDecision(policy.version, "ready", primary, tuple(parallel), tuple(flags), ())
+2
View File
@@ -4,6 +4,8 @@ The only module (besides registration code) that touches both schemas — and on
"""
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase
from app.db.session import clinical_engine, identity_engine
+55
View File
@@ -0,0 +1,55 @@
"""Versioned questionnaire records, without modifying historical scale results."""
from datetime import date, datetime
from typing import Any
from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import BigId, ClinicalBase
from app.db.types import JSONType, TZDateTime
class QuestionnaireRecord(ClinicalBase):
__tablename__ = "questionnaire_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
token: Mapped[str] = mapped_column(String(32), index=True)
kind: Mapped[str] = mapped_column(String(32))
version: Mapped[str] = mapped_column(String(64))
policy_version: Mapped[str] = mapped_column(String(64))
started_at: Mapped[datetime] = mapped_column(TZDateTime)
completed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
day: Mapped[date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(32))
answers: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
# Per-field time, recall period, source and derived values; totals may be null.
details: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
class QuestionnaireNotice(ClinicalBase):
__tablename__ = "questionnaire_notices"
__table_args__ = (UniqueConstraint("token", "event_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
event_key: Mapped[str] = mapped_column(String(128))
alert_id: Mapped[int] = mapped_column(ForeignKey("alerts.id"))
created_at: Mapped[datetime] = mapped_column(TZDateTime)
attempted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
accepted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
attempts: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(32), default="pending")
error_code: Mapped[str | None] = mapped_column(String(80))
class QuestionnaireTask(ClinicalBase):
__tablename__ = "questionnaire_tasks"
__table_args__ = (UniqueConstraint("token", "task_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
task_key: Mapped[str] = mapped_column(String(128))
kind: Mapped[str] = mapped_column(String(32))
due_at: Mapped[datetime] = mapped_column(TZDateTime)
status: Mapped[str] = mapped_column(String(32), default="pending")
record_id: Mapped[str | None] = mapped_column(String(32))
delivery: Mapped[list] = mapped_column(JSONType, default=list)
sent_at: Mapped[datetime | None] = mapped_column(TZDateTime)
+24
View File
@@ -0,0 +1,24 @@
"""Delivery log of the demo scheduler (#13): one row per delivered event, the unique key makes runs idempotent.
Clinical DB, keyed by token only (guardrail #5). Created by `init_db` (`app.db.bootstrap`).
"""
from datetime import datetime
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import TOKEN_LEN, BigId, ClinicalBase
from app.db.types import TZDateTime
class SchedulerEvent(ClinicalBase):
__tablename__ = "scheduler_events"
__table_args__ = (UniqueConstraint("token", "key", name="uq_scheduler_events_token_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(TOKEN_LEN))
key: Mapped[str] = mapped_column(String(96)) # e.g. "dose:3:2026-10-06T08:00", "checkin:2026-10-06"
kind: Mapped[str] = mapped_column(String(32)) # med_reminder | missed | daily_checkin | scale_due | evaluation
due_at: Mapped[datetime] = mapped_column(TZDateTime)
done_at: Mapped[datetime] = mapped_column(TZDateTime)
+85
View File
@@ -0,0 +1,85 @@
"""Execution of *dialog* actions returned by `MonitoringService.after_data_change()` (ADR-007).
The rules (lane B, `config/rules.yaml`) decide WHAT happens; this module only turns an already chosen action
into replies. `alert` / `enhanced_monitoring` are handled by lane B itself (persisted + doctor notified), so they
are ignored here. While a crisis is open only crisis-relevant actions (crisis, safety plan, risk screen) reach
the patient: no medication tips or visit offers in the middle of a crisis.
"""
from collections.abc import Awaitable, Callable, Iterable
from typing import Any
from app.core.rules import Action
from app.db.clinical import SafetyPlan
from app.dialog.contracts import Button, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Ctx, Step
from app.dialog.scale_session import active_scale, add_pending
# med_info topic → phrase intent of `config/texts/phrases.yaml` (the LLM only rephrases the template).
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
# Optional `med_info` answerer registered by a flow (`flows/meds.py`, #37): answers from the drug instruction
# (med_qa). Returning None falls back to the phrase template below.
MedInfoHandler = Callable[[Ctx, Action], Awaitable[Reply | None]]
_med_info_handler: MedInfoHandler | None = None
def register_med_info_handler(handler: MedInfoHandler | None) -> None:
global _med_info_handler
_med_info_handler = handler
def _plan_line(step: Any) -> str:
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan_reply(ctx: Ctx, block: str | None = None) -> Reply:
"""The plan composed with the doctor (`safety_plans`), or the Stanley–Brown template from config."""
row = await ctx.session.get(SafetyPlan, ctx.token)
steps = [_plan_line(s) for s in row.steps] if row is not None and row.steps else ctx.texts.safety_plan.template
lines = [ctx.texts.safety_plan.title, *(f"{i}. {s}" for i, s in enumerate(steps, 1))]
if block == "means_restriction":
lines += ["", ctx.texts.safety_plan.means_restriction]
return Reply(text="\n".join(lines), kind="safety_plan")
async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
step = Step()
shown_plan = False
for action in actions:
if crisis_open({**ctx.data, **step.data}) and action.type not in _CRISIS_SAFE:
continue
if action.type == "crisis":
if not crisis_open({**ctx.data, **step.data}):
step = step.then(open_crisis(ctx, categories=[], source=f"rule:{action.code}"))
elif action.type == "show_safety_plan" and not shown_plan:
shown_plan = True
step = step.then(Step(replies=[await safety_plan_reply(ctx, action.params.get("block"))]))
elif action.type == "schedule_scale" and action.params.get("scale"):
scale_id = str(action.params["scale"])
if active_scale(step.next_state or ctx.state) == scale_id:
continue # already being asked
data = {**ctx.data, **step.data}
pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False)))
step = step.then(Step(data={"pending_scales": pending}))
elif action.type == "med_info":
reply = await _med_info_handler(ctx, action) if _med_info_handler is not None else None
if reply is None:
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
reply = Reply(text=await ctx.gateway.phrase(intent, {}), kind="info")
step = step.then(Step(replies=[reply]))
elif action.type == "suggest_visit":
text = await ctx.gateway.phrase("book_visit_offer", {})
button = Button(label=ctx.texts.visit.button, data="menu:visit")
step = step.then(Step(replies=[Reply(text=text, buttons=[[button]], kind="info")]))
elif action.type == "reach_out":
topic = str(action.params.get("topic", "default"))
texts = ctx.texts.reach_out
text = {"silence": texts.silence, "missed_doses": texts.missed_doses}.get(topic, texts.default)
step = step.then(Step(replies=[Reply(text=text)]))
# alert / enhanced_monitoring: lane B persists and notifies; nothing to say to the patient.
return step
+49
View File
@@ -0,0 +1,49 @@
"""Crisis path of the dialog (guardrail #2): fixed texts only, never generated by the LLM.
Decisions:
* The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per
open crisis. "Open" = `data["crisis"]["open"]` — set when the crisis starts, cleared only by the doctor's
acknowledgement (`doctor_ack`), so a patient repeating the phrase does not get the same long script again:
they get a short fixed support message and emergency resources instead.
* Entering the crisis drops unfinished scale sessions (their answered items are already stored item by item):
nothing else is asked while the patient is in crisis, except the C-SSRS screen.
* Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message —
protecting life is the one processing ground that does not need consent (152-FZ art. 10 p. 2.3) — but with
a variant WITHOUT "your doctor has been notified", because no doctor is notified, and nothing is stored.
"""
from typing import Any
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
from app.dialog.contracts import Reply
from app.dialog.fsm import Ctx, Step
def crisis_open(data: dict[str, Any]) -> bool:
return bool((data.get("crisis") or {}).get("open"))
def _resource_lines(resources: list[CrisisResource]) -> list[str]:
return [f"• {r.name}: {r.phone}" + (f" ({r.note})" if r.note else "") for r in resources]
def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
"""Start the crisis (full script) or, if one is already open, send the short repeat text.
`source` is a code for the dashboard/audit trail: "safety:lexicon", "safety:llm", "scale:phq9", "rule:<id>".
"""
script = crisis_reply(categories)
if crisis_open(ctx.data):
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
return Step(replies=[Reply(text=text, kind="crisis")])
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
return Step(
replies=[Reply(text=script.text, kind="crisis")],
next_state="crisis",
data={"crisis": crisis, "scales": None},
)
def no_consent_crisis_text(script: CrisisScript) -> str:
"""Crisis script without the "doctor notified" line — for people we may not (or did not) notify about."""
return "\n".join([script.body, "", *_resource_lines(script.resources), "", script.grounding])
+406
View File
@@ -0,0 +1,406 @@
"""Dialog engine (issue #10, ADR-004): `Engine.handle(token, event) -> list[Reply]`, channel-agnostic.
Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
0. abandoned step the FSM sits in a question step (check-in, scale) the patient left more than
`settings.abandon_after_hours` ago → back to idle first (stored answers stay; never in
crisis, never in an onboarding chain), then the event is handled as usual.
text event
1. scrub() raw text → ScrubbedText. Raw text is never stored, logged or sent anywhere.
2. consent gate no valid consent → no LLM, nothing stored; only the local crisis lexicon runs
(protecting life does not need consent) and the core flow shows consent/refusal.
3. safety.check() lexicon FIRST. Lexicon ACUTE → the LLM is not called at all. Otherwise the LLM risk
classifier is an auxiliary signal combined as max(lexicon, llm): it can only raise risk.
ACUTE → fixed crisis script verbatim (once per open crisis), state `crisis`, structured
`safety_events` fact for lane B, MonitoringService.after_data_change() (lane B alerts).
POSSIBLE → C-SSRS screen (scale session) and back to the previous state (or crisis if high).
4. FSM the current state's handler (`app/dialog/flows/*`, `app/dialog/scale_session.py`).
button / system / start events skip 1–3 (no free text) and go through the same FSM.
After the FSM step: if clinical data were written → commit → `after_data_change()` → the returned *dialog*
actions are executed (`app/dialog/actions.py`). The engine never creates alerts (ADR-007).
Then a queued scale starts if the FSM rests (idle; only the C-SSRS while in crisis).
Persist: inbound message (scrubbed) + outbound replies + `dialog_state` — only with active consent.
Logs carry only the token, states and event codes (guardrail #8).
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub
from app.core.safety import SafetyResult, crisis_reply
from app.core.types import RiskLevel, ScrubbedText
from app.db import repo
from app.db.session import get_clinical_session
from app.dialog import scale_session
from app.dialog.actions import execute
from app.dialog.contracts import Event, Reply, register_engine
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
from app.dialog.flows import load_flows
from app.dialog.flows.core import close_crisis, revoke
from app.dialog.fsm import (
NO_CONSENT_STATES,
RESTING_STATES,
Ctx,
Step,
apply_data,
enter_hook_for,
handler_for,
is_abandonable,
)
from app.dialog.texts import DialogTexts, load_dialog_texts
from app.llm.gateway import LLMGateway, get_gateway
from app.services import questionnaire as intake_service
from app.services.contracts import MonitoringService, get_monitoring
log = logging.getLogger(__name__)
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
MAX_ENTER_CHAIN = 8
_patient_locks = WeakValueDictionary()
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
_DB_DOWN = (OSError, OperationalError, InterfaceError)
# dialog_state.data key: when the patient last acted in (or the FSM entered) the current step — abandon timeout.
STEP_AT = "step_at"
@dataclass
class _Run:
replies: list[Reply] = field(default_factory=list)
notify: bool = False
def _consented(ctx: Ctx) -> bool:
return ctx.data.get("consent") is True and ctx.state not in NO_CONSENT_STATES
class Engine:
"""Real `DialogEngine`. `gateway` / `monitoring` are injectable for tests; by default resolved per call so
a monitoring implementation registered later (lane B, #38) is picked up."""
def __init__(self, *, gateway: LLMGateway | None = None, monitoring: MonitoringService | None = None) -> None:
self._gateway = gateway
self._monitoring = monitoring
async def handle(self, token: str, event: Event) -> list[Reply]:
texts = load_dialog_texts()
try:
lock = _patient_locks.setdefault(token, asyncio.Lock())
async with lock:
async with get_clinical_session() as session:
await intake_service.lock_patient(session, token)
replies = await self._handle(token, event, session, texts)
await intake_service.dispatch_notices(token)
return replies
except _DB_DOWN as exc:
log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__)
return _degraded(event, texts)
# ------------------------------------------------------------------------------------------ pipeline
async def _handle(self, token: str, event: Event, session: AsyncSession, texts: DialogTexts) -> list[Reply]:
row = await repo.get_dialog_state(session, token)
ctx = Ctx(
token=token,
state=row.state if row is not None else "new",
data=dict(row.data or {}) if row is not None else {},
now=clock.now(),
session=session,
gateway=self._gateway or get_gateway(),
monitoring=self._monitoring or get_monitoring(),
texts=texts,
)
state_before = ctx.state
run = _Run()
if self._abandoned(ctx):
await self._apply(ctx, self._abandon(ctx), run)
await self._apply(ctx, await self._route(ctx, event), run)
if run.notify:
# Monitoring (lane B) reads the DB in its own session: commit our writes first.
await self._save_state(ctx)
await session.commit()
actions = await ctx.monitoring.after_data_change(token)
await self._apply(ctx, await execute(ctx, actions), run)
await self._apply(ctx, await self._start_pending(ctx), run)
if _consented(ctx):
for reply in run.replies:
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
await repo.add_message(
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
)
if event.type != "system" or ctx.state != state_before:
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
ctx.data[STEP_AT] = ctx.now.isoformat()
await self._save_state(ctx)
task_id = event.params.get("questionnaire_task")
if event.type == "system" and task_id and run.replies:
from app.db.intake import QuestionnaireTask
task = await session.get(QuestionnaireTask, task_id)
if task and task.token == token:
task.delivery = [r.model_dump() for r in run.replies]
log.info(
"dialog token=%s event=%s code=%s state=%s->%s replies=%d",
token,
event.type,
_event_code(event),
state_before,
ctx.state,
len(run.replies),
)
return run.replies
def _abandoned(self, ctx: Ctx) -> bool:
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
return False
hours = ctx.texts.settings.abandon_after_hours
last = ctx.data.get(STEP_AT)
if not hours or not last or ctx.now - datetime.fromisoformat(last) < timedelta(hours=hours):
return False
return is_abandonable(ctx)
def _abandon(self, ctx: Ctx) -> Step:
"""Drop the unfinished step's progress (answers already stored in item_responses / daily_checkins stay;
an unfinished scale gets no scale_results row) and rest in idle so queued scales may start."""
log.info("dialog step abandoned token=%s state=%s", ctx.token, ctx.state)
scale_id = scale_session.active_scale(ctx.state)
scales = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale_id}
return Step(next_state="idle", data={"checkin": None, "scales": scales or None})
async def _route(self, ctx: Ctx, event: Event) -> Step:
consented = _consented(ctx)
if event.type == "text":
# (1) scrub before anything else: from here on only ScrubbedText exists for this message.
ctx.text = scrub(event.text or "")
if consented and _is_revoke_command(event.text or "", ctx.texts):
return await revoke(ctx)
if not consented:
return await self._no_consent_text(ctx, event)
await self._store_in(ctx, ctx.text)
return await self._safety(ctx, event)
if event.type == "button":
if consented and event.button == "consent:revoke":
return await revoke(ctx)
if consented and event.button == "intake:help":
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
return await urgent(ctx, row)
if consented:
await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client
return await self._dispatch(ctx, event)
if event.type == "system":
if event.system == "doctor_ack":
return close_crisis(ctx)
if not consented:
return Step() # nothing is pushed to people without consent
if event.system == "scale_due" and event.params.get("scale"):
if intake_enabled() and ctx.data.get("adult") is True:
scale = str(event.params["scale"])
kind = "cssrs_since_contact" if scale in ("cssrs_screen", "cssrs") else scale
if kind in ("phq9", "pcl5", "rus_audit_s", "cssrs_since_contact"):
await intake_service.queue_task(
ctx.session, ctx.token, kind, f"external:{kind}:{ctx.now.date()}", ctx.now
)
return Step()
scheduled = bool(event.params.get("scheduled", True))
pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled)
return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step
return await self._dispatch(ctx, event)
return await self._dispatch(ctx, event) # start
async def _no_consent_text(self, ctx: Ctx, event: Event) -> Step:
assert ctx.text is not None
# Lexicon only: without consent the text must not reach the LLM (third-party processing).
result = safety.check(ctx.text)
if result.level == RiskLevel.ACUTE:
log.warning("safety acute without consent token=%s rules=%s", ctx.token, result.matched_rules)
return Step(replies=[Reply(text=no_consent_crisis_text(crisis_reply(result.categories)), kind="crisis")])
return await self._dispatch(ctx, event)
async def _safety(self, ctx: Ctx, event: Event) -> Step:
"""Guardrail #2: the safety decision is made before any LLM sees the message for anything else."""
assert ctx.text is not None
lexicon = safety.check(ctx.text)
if lexicon.level == RiskLevel.ACUTE:
result = lexicon # an acute message never reaches the LLM — not even the risk classifier
else:
result = safety.check(ctx.text, llm_signal=await ctx.gateway.risk_classify(ctx.text))
if result.level == RiskLevel.ACUTE:
handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script"
log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
step = await urgent(ctx, row, code="safety_acute", categories=result.categories)
else:
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True # lane B escalates to the doctor on call
return step
if result.level == RiskLevel.POSSIBLE and (
ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True)
):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import manual_review
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
if row is None:
row = await intake_service.create_record(ctx, "cssrs_since_contact")
return await manual_review(ctx, row, "cssrs_since_contact_required")
if result.level == RiskLevel.POSSIBLE:
screen = ctx.texts.settings.risk_screen_scale
if self._can_screen(ctx, screen):
step = await scale_session.start_scale(
ctx, screen, return_state=ctx.state, scheduled=False, intro=ctx.texts.scale.risk_screen_intro
)
step.data["risk_screen_at"] = ctx.now.isoformat()
handled = "risk_screen"
else:
step = await self._dispatch(ctx, event)
handled = "dispatched"
log.info("safety possible token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True
return step
return await self._dispatch(ctx, event)
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
return False
last = ctx.data.get("risk_screen_at")
if last is None:
return True
hours = (ctx.now - datetime.fromisoformat(last)).total_seconds() / 3600
return hours >= ctx.texts.settings.cssrs_cooldown_hours
def _safety_events(self, ctx: Ctx, result: SafetyResult, handled: str) -> list[dict[str, Any]]:
"""Structured safety fact for lane B (#38): no text, only codes. Newest last, capped."""
event = {
"ts": ctx.now.isoformat(),
"level": result.level.name.lower(),
"categories": result.categories,
"rules": result.matched_rules,
"source": result.source,
"handled": handled,
"state": ctx.state,
}
events = [*(ctx.data.get("safety_events") or []), event]
return events[-ctx.texts.settings.max_safety_events :]
async def _dispatch(self, ctx: Ctx, event: Event) -> Step:
handler = handler_for(ctx.state)
if handler is None:
# A state from a removed/renamed flow: recover instead of getting stuck.
log.error("dialog unknown state token=%s state=%s", ctx.token, ctx.state)
return Step(next_state="idle" if _consented(ctx) else "new")
return await handler(ctx, event)
async def _apply(self, ctx: Ctx, step: Step, run: _Run, depth: int = 0) -> None:
run.replies.extend(step.replies)
apply_data(ctx.data, step.data)
run.notify = run.notify or step.notify_monitoring
if not step.next_state or step.next_state == ctx.state:
return
ctx.state = step.next_state
hook = enter_hook_for(ctx.state)
if hook is None:
return
if depth >= MAX_ENTER_CHAIN:
log.error("dialog enter chain too long token=%s state=%s", ctx.token, ctx.state)
return
await self._apply(ctx, await hook(ctx), run, depth + 1)
async def _start_pending(self, ctx: Ctx) -> Step:
"""Start a queued scale when nothing else is being asked. In crisis only the risk screen may run."""
if intake_enabled():
return Step()
pending = list(ctx.data.get("pending_scales") or [])
if not pending or ctx.state not in RESTING_STATES:
return Step()
screen = ctx.texts.settings.risk_screen_scale
dropped: dict[str, Any] = {}
if any(p["scale"] == screen for p in pending) and await scale_session.recent_risk_screen(ctx, screen):
# Item 9 / rule phq9_item9 / scale_due asked for a C-SSRS the patient completed within the cooldown:
# do not repeat the same suicide screen (#56). Crisis script and alerts are unaffected.
log.info("dialog risk screen skipped (cooldown) token=%s", ctx.token)
pending = [p for p in pending if p["scale"] != screen]
dropped = {"pending_scales": pending or None}
if not pending:
return Step(data=dropped)
if ctx.state == "crisis":
pending_screen = [p for p in pending if p["scale"] == screen]
if not pending_screen:
return Step(data=dropped)
item = pending_screen[0]
else:
item = pending[0]
rest = [p for p in pending if p is not item]
step = await scale_session.start_scale(
ctx,
item["scale"],
return_state=ctx.state,
scheduled=bool(item.get("scheduled", True)),
intro=ctx.texts.scale.risk_screen_intro if item["scale"] == screen else None,
)
step.data["pending_scales"] = rest or None
return step
async def _store_in(self, ctx: Ctx, text: ScrubbedText) -> None:
await repo.add_message(ctx.session, token=ctx.token, direction="in", text=text, ts=ctx.now, fsm_state=ctx.state)
async def _save_state(self, ctx: Ctx) -> None:
await repo.set_dialog_state(ctx.session, token=ctx.token, state=ctx.state, data=ctx.data, now=ctx.now)
def _is_revoke_command(raw: str, texts: DialogTexts) -> bool:
return raw.strip().lower().rstrip(".!") in texts.commands.revoke
def _event_code(event: Event) -> str:
if event.type == "button":
return event.button or ""
if event.type == "system":
return event.system or ""
return "-"
def _degraded(event: Event, texts: DialogTexts) -> list[Reply]:
"""DB down: still answer a crisis message (lexicon only, no "doctor notified" — nobody could be notified)."""
if event.type == "text" and event.text:
result = safety.check(scrub(event.text))
if result.level == RiskLevel.ACUTE:
return [Reply(text=no_consent_crisis_text(crisis_reply(result.categories)), kind="crisis")]
if event.type == "system":
return []
return [Reply(text=texts.unavailable, kind="info")]
load_flows()
register_engine(Engine)
+16
View File
@@ -0,0 +1,16 @@
"""Dialog flows. Each module here registers its FSM states at import time (`@state`, `@on_enter`, `@trigger`).
A new direction (veteran #36/#37, family, depression) is a new file `app/dialog/flows/<module>.py`; it is picked
up automatically by `load_flows()` — the engine is not edited.
"""
import pkgutil
from importlib import import_module
def load_flows() -> None:
"""Import every flow module of this package (core first, so flows may override core triggers)."""
import_module(f"{__name__}.core")
for info in sorted(pkgutil.iter_modules(__path__), key=lambda m: m.name):
if info.name != "core" and not info.name.startswith("_"):
import_module(f"{__name__}.{info.name}")
+355
View File
@@ -0,0 +1,355 @@
"""Core flow shared by all modules: connect → consent → age gate → module onboarding → idle; crisis; revocation.
new ──start(code)──▶ consent ──«Согласен»──▶ age_gate ──adult──▶ <module>.onboarding.start … ──▶ idle
│ └──< 18──▶ minor (stop, child helpline)
└──«Не согласен»──▶ refused (stop, /start to reconsider)
any consented state ──«Отозвать согласие»──▶ consent_revoked (silent; /start → consent again)
any ──ACUTE / item 9 / C-SSRS high──▶ crisis ──doctor_ack──▶ idle
Consent and age are the registration step, so this module (and only it) touches the identity DB, through
`app.db.identity_repo`, and only for consent / revocation / birth year (guardrail #5).
"""
import re
from app.db import identity_repo, repo
from app.db.session import get_identity_session
from app.dialog.actions import safety_plan_reply
from app.dialog.contracts import Button, Event, Reply
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, on_enter, state, trigger
from app.integrations.base import integrations
from app.services.contracts import NoopMonitoring
ADULT_AGE = 18
_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
_AGE_RE = re.compile(r"^\s*(\d{1,3})\s*(лет|год|года)?\s*$")
# ------------------------------------------------------------------------------------------- replies
def consent_reply(ctx: Ctx) -> Reply:
t = ctx.texts.consent
buttons = [[Button(label=t.accept, data="consent:yes")], [Button(label=t.decline, data="consent:no")]]
return Reply(text=t.text, buttons=buttons, kind="question")
def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
b = ctx.texts.idle.buttons
buttons = [
[Button(label=b.checkin, data="menu:checkin")],
[Button(label=b.med_question, data="menu:med_question")],
[Button(label=b.kb_question, data="menu:kb_question")],
[Button(label=b.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")],
]
from app.core.intake_questions import texts
from app.core.intake_runtime import intake_enabled
if intake_enabled():
buttons.insert(0, [Button(label=texts()["help"], data="intake:help")])
buttons.insert(
0,
[
Button(
label=texts()["resume"] if ctx.data.get("questionnaire_record") else texts()["start"],
data="intake:resume" if ctx.data.get("questionnaire_record") else "intake:start",
)
],
)
return Reply(text=text or ctx.texts.idle.menu_prompt, buttons=buttons)
# ------------------------------------------------------------------------------------------- onboarding API
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
# person has no profile at all before it.
if after is None and intake_enabled():
return "intake.start"
if after in modules:
modules = modules[modules.index(after) + 1 :]
for module in modules:
if is_registered(f"{module}.onboarding.start"):
return f"{module}.onboarding.start"
return "idle"
async def finish_onboarding(ctx: Ctx, module: str | None = None) -> Step:
"""A module flow calls this when its onboarding is done: next module's onboarding, or idle + welcome."""
nxt = await onboarding_entry(ctx, after=module)
if nxt == "idle":
return Step(replies=[menu_reply(ctx, ctx.texts.idle.welcome)], next_state="idle")
return Step(next_state=nxt)
# ------------------------------------------------------------------------------------------- new / consent
@state("new")
async def _new(ctx: Ctx, event: Event) -> Step:
code = event.params.get("code") if event.type == "start" else None
if code:
# Telegram deep link carries the doctor's one-time connect code; the web console passes the token
# directly (no code) — then the token is already known to the system.
person = await integrations.identity.verify(str(code))
if person is None or person.token != ctx.token:
return Step(replies=[Reply(text=ctx.texts.consent.code_invalid)])
if await ctx.profile() is None:
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id=person.doctor_id)
return Step(replies=[consent_reply(ctx)], next_state="consent")
@state("consent")
async def _consent(ctx: Ctx, event: Event) -> Step:
if event.type == "button" and event.button == "consent:yes":
version = ctx.texts.consent.version
async with get_identity_session() as s:
# None when the token has no identity record (web demo console) — the dialog state keeps the fact.
await identity_repo.record_consent(s, token=ctx.token, version=version, now=ctx.now)
data = {"consent": True, "consent_ver": version, "consent_at": ctx.now.isoformat(), "revoked_at": None}
return Step(next_state="age_gate", data=data)
if event.type == "button" and event.button == "consent:no":
return _refused_step(ctx)
# Anything else (incl. free text) is not processed before consent: show the consent text again.
return Step(replies=[consent_reply(ctx)])
def _refused_step(ctx: Ctx) -> Step:
button = Button(label=ctx.texts.consent.reconsider, data="consent:restart")
return Step(replies=[Reply(text=ctx.texts.consent.refused, buttons=[[button]])], next_state="refused")
@state("refused")
async def _refused(ctx: Ctx, event: Event) -> Step:
if event.type == "start" or (event.type == "button" and event.button == "consent:restart"):
return Step(replies=[consent_reply(ctx)], next_state="consent")
if event.type == "system":
return Step()
return _refused_step(ctx)
# ------------------------------------------------------------------------------------------- age gate
def _age_verdict(birth_year: int, current_year: int) -> str:
"""Without the birthday the age is `current - birth_year` or one less; ask when the two disagree on 18."""
oldest, youngest = current_year - birth_year, current_year - birth_year - 1
if youngest >= ADULT_AGE:
return "adult"
if oldest < ADULT_AGE:
return "minor"
return "unsure"
def _age_question(ctx: Ctx, note: str = "") -> Reply:
t = ctx.texts.age_gate
buttons = [[Button(label=t.adult, data="age:adult")], [Button(label=t.minor, data="age:minor")]]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
@on_enter("age_gate")
async def _age_enter(ctx: Ctx) -> Step:
profile = await ctx.profile()
birth_year = profile.birth_year if profile is not None else None
if birth_year is None:
async with get_identity_session() as s:
patient = await identity_repo.get_patient(s, ctx.token)
birth_year = patient.birth_year if patient is not None else None
if birth_year is not None:
verdict = _age_verdict(birth_year, ctx.now.year)
if verdict == "adult":
return await _adult(ctx)
if verdict == "minor":
return await _minor(ctx)
return Step(replies=[_age_question(ctx)])
@state("age_gate")
async def _age_gate(ctx: Ctx, event: Event) -> Step:
if event.type == "button" and event.button == "age:adult":
return await _adult(ctx)
if event.type == "button" and event.button == "age:minor":
return await _minor(ctx)
if event.type == "text" and ctx.text:
year = _YEAR_RE.search(ctx.text)
age_match = _AGE_RE.match(ctx.text)
if year:
birth_year = int(year.group(1))
verdict = _age_verdict(birth_year, ctx.now.year)
if verdict == "adult":
return await _adult(ctx, birth_year)
if verdict == "minor":
return await _minor(ctx)
elif age_match:
age = int(age_match.group(1))
return await (_adult(ctx) if age >= ADULT_AGE else _minor(ctx))
return Step(replies=[_age_question(ctx, ctx.texts.age_gate.unclear)])
return Step(replies=[_age_question(ctx)])
async def _adult(ctx: Ctx, birth_year: int | None = None) -> Step:
if birth_year is not None and await ctx.profile() is not None:
await repo.upsert_profile(ctx.session, token=ctx.token, birth_year=birth_year)
return Step(data={"adult": True}).then(await finish_onboarding(ctx))
async def _minor(ctx: Ctx) -> Step:
# A minor cannot give this consent on their own: withdraw it and stop processing.
async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
return Step(
replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")],
next_state="minor",
data={"consent": False, "minor": True},
)
@state("minor")
async def _minor_state(ctx: Ctx, event: Event) -> Step:
if event.type == "system":
return Step()
return Step(replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")])
# ------------------------------------------------------------------------------------------- idle
@state("idle")
async def _idle(ctx: Ctx, event: Event) -> Step:
if event.type == "start":
return Step(replies=[menu_reply(ctx)])
if event.type == "button" and event.button:
handler = get_trigger(f"button:{event.button}")
return await handler(ctx, event) if handler else Step(replies=[menu_reply(ctx)])
if event.type == "system" and event.system:
handler = get_trigger(f"system:{event.system}")
return await handler(ctx, event) if handler else Step()
if event.type == "text":
handler = get_trigger("text:idle")
if handler:
return await handler(ctx, event)
# Neutral empathic reply; the LLM only rephrases the approved template (and is filtered).
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))])
return Step()
# Default idle triggers — flows (#15, #36, #37) override them with `@trigger(..., replace=True)`.
@trigger("button:menu:checkin")
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
@trigger("button:menu:med_question")
async def _menu_med_question(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.stub_med_question)])
@trigger("button:menu:contact_doctor")
async def _menu_contact_doctor(ctx: Ctx, event: Event) -> Step:
# A fact for lane B (#38): the patient asked the doctor to get in touch. No alert is created here (ADR-007).
requests = [*(ctx.data.get("contact_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
return Step(
replies=[Reply(text=ctx.texts.idle.contact_doctor_ack)],
data={"contact_requests": requests},
notify_monitoring=True,
)
@trigger("button:menu:visit")
async def _menu_visit(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.visit_stub)])
@trigger("button:safety_plan")
async def _show_plan(ctx: Ctx, event: Event) -> Step:
return Step(replies=[await safety_plan_reply(ctx)])
@trigger("system:med_reminder")
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
drug = str(event.params.get("drug", ""))
return Step(replies=[Reply(text=await ctx.gateway.phrase("med_reminder", {"drug": drug}))])
@trigger("system:daily_checkin")
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
return Step(replies=[menu_reply(ctx, ctx.texts.idle.stub_checkin)])
@trigger("system:reach_out")
async def _reach_out(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.reach_out.default)])
# ------------------------------------------------------------------------------------------- crisis
def close_crisis(ctx: Ctx) -> Step:
"""Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards."""
if not (ctx.data.get("crisis") or {}).get("open") and ctx.state != "crisis":
return Step()
crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()}
scales = {
sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s
for sid, s in (ctx.data.get("scales") or {}).items()
}
step = Step(data={"crisis": crisis, "scales": scales})
if ctx.state == "crisis":
step.next_state = "idle"
step.replies.append(Reply(text=ctx.texts.crisis.closed))
return step
@state("crisis")
async def _crisis(ctx: Ctx, event: Event) -> Step:
# The NoopMonitoring stub knows nothing about crises and always says "not in crisis"; trusting it would
# end every crisis on the next message. Until lane B (#38) lands only `doctor_ack` ends a crisis.
if not isinstance(ctx.monitoring, NoopMonitoring) and not await ctx.monitoring.is_in_crisis(ctx.token):
return close_crisis(ctx)
if event.type == "system":
return Step() # reminders and check-ins wait until the crisis is over
if event.type == "button" and event.button == "safety_plan":
return Step(replies=[await safety_plan_reply(ctx)])
plan = Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")
return Step(replies=[Reply(text=ctx.texts.crisis.hold, buttons=[[plan]], kind="crisis")])
# ------------------------------------------------------------------------------------------- revocation
async def revoke(ctx: Ctx) -> Step:
"""Withdraw consent in one step (152-FZ art. 9 p. 2): identity DB + dialog state; the bot goes silent.
Clinical data are marked for deletion (`data["delete_requested"]`) — erasure itself is lane B's job.
"""
async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
from app.services.questionnaire import cancel_pending
await cancel_pending(ctx.session, ctx.token)
data = {
"questionnaire_record": None,
"consent": False,
"revoked_at": ctx.now.isoformat(),
"delete_requested": True,
"scales": None,
"pending_scales": None,
}
return Step(replies=[Reply(text=ctx.texts.revoked.confirm)], next_state="consent_revoked", data=data)
@state("consent_revoked")
async def _revoked(ctx: Ctx, event: Event) -> Step:
if event.type == "start":
return Step(replies=[consent_reply(ctx)], next_state="consent")
return Step() # silent: nothing is processed after revocation
+450
View File
@@ -0,0 +1,450 @@
"""Versioned, resumable questionnaires. Disabled until the clinical release gate opens."""
import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.core import interpretation
from app.core.intake_questions import (
DIARY_KINDS,
Question,
content,
cssrs_answers,
cssrs_since_contact_question,
diary_questions,
group_answers,
next_intake,
scale_question,
texts,
)
from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import repo
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service
def allowed(ctx):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
def side_effects_question():
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
def next_question(row):
a = row.answers
if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "cssrs_since_contact":
return cssrs_since_contact_question(a)
if row.kind in DIARY_KINDS:
part = row.kind.removeprefix("diary_")
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
if q.id in a:
continue
if q.id == "medication_reason" and a.get("medication") != "no":
continue
return q
return None
if row.kind in ("phq9", "pcl5", "rus_audit_s"):
sid = row.kind
if sid == "rus_audit_s":
short = score_alcohol(
{k: a[k] for k in load_scale(sid).item_ids if k in a}, short=True, sex=row.details.get("sex")
)
if alcohol_expansion(short, load_policy()):
sid = "rus_audit"
for item in load_scale(sid).item_ids:
if item == "rus_audit_2" and a.get("rus_audit_1") == 0:
continue
if item not in a:
q = scale_question(sid, item)
if sid == "pcl5" and row.details["weekly_pcl"]:
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q
if row.details.get("medication_active") and "side_effects" not in a:
return side_effects_question()
return None
def question_reply(row, q):
t = texts()
# Nonce + item + option index rejects old/replayed callbacks without exposing raw answers.
prefix = f"iq:{row.id[:12]}:{q.id}:"
buttons = [[Button(label=label, data=prefix + str(i))] for i, (_, label) in enumerate(q.options)]
if q.optional:
buttons.append([Button(label=t["comment_skip"], data=prefix + "skip")])
buttons.extend([[Button(label=t["help"], data="intake:help")], [Button(label=t["pause"], data="intake:pause")]])
return Reply(text=q.text + "\n\n" + t["as_of"].format(day=row.day.isoformat()), buttons=buttons, kind="question")
async def urgent(ctx, row=None, *, code="help_now", categories=None):
if crisis_open(ctx.data):
return open_crisis(ctx, categories or [], source="questionnaire:" + code)
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required")
if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
await service.alert(
ctx.session,
ctx.token,
f"{row.id if row else ctx.now.isoformat()}:{code}",
code,
ctx.now,
level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags},
)
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True
for reply in step.replies:
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
return step
async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert(
ctx.session, ctx.token, f"{row.id}:{code}", code, ctx.now, level=AlertLevel.HIGH, params={"record_id": row.id}
)
return Step(
replies=[
Reply(
text=texts()["missing_followup"] if code == "cssrs_since_contact_required" else texts()["review"],
buttons=[[Button(label=texts()["help"], data="intake:help")]],
kind="info",
)
],
next_state="idle",
data={"questionnaire_record": None, "intake_review_required": True},
)
async def advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts:
return await manual_review(ctx, row, "cssrs_conflict")
# Finish the risk questions before any routine question; a moderate result stops here.
if c.complete and c.risk == "moderate":
return await manual_review(ctx, row, "urgent_psychiatrist")
if c.complete and c.risk == "low":
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
row.id + ":cssrs_low",
"cssrs_low_safety_plan",
ctx.now,
params={"record_id": row.id},
)
try:
q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1.
score_alcohol(group_answers(a, "rus_audit"), short=False, sex=row.details.get("sex"))
except ValueError:
return await manual_review(ctx, row, "answer_conflict")
if q:
replies = [question_reply(row, q)]
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
row.details = {**row.details, "plan_shown": True}
return Step(replies=replies, next_state="intake.collect", data={"questionnaire_record": row.id})
extra = {}
if a.get("side_effects") == 1:
await service.alert(
ctx.session,
ctx.token,
row.id + ":side_effects_review",
"side_effects_review",
ctx.now,
params={"record_id": row.id},
)
if row.kind == "intake":
result = service.decision(row)
if result.status != "ready":
return await manual_review(ctx, row, "intake_incomplete_review")
extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
await apply_modules(ctx, extra["interpretation"]["modules"])
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert(
ctx.session,
ctx.token,
row.id + ":route",
"intake_route_review",
ctx.now,
params={"record_id": row.id, **extra},
)
elif row.kind == "cssrs_since_contact":
extra["totals"] = {"cssrs_recent": asdict(c)}
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
elif row.kind in DIARY_KINDS:
# Morning and evening answers of one date form one diary day.
history: dict = {}
for r in await service.records(ctx.session, ctx.token):
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
history.setdefault(r.day, {}).update(r.answers)
history.setdefault(row.day, {}).update(a)
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
extra["flags"] = flags
for flag in flags:
await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
)
if flag in DIARY_SCALE_TASKS:
sid = DIARY_SCALE_TASKS[flag]
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
else:
extra["totals"] = service.summary(row)
runtime = load_runtime()
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at")
specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = (
"priority_psychiatrist"
if total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
earlier = [
r
for r in await service.records(ctx.session, ctx.token)
if r.id != row.id
and r.status == "complete"
and r.version == row.version
and r.details.get("totals", {}).get(sid, {}).get("complete")
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
]
prior = earlier[-1] if earlier else None
if prior:
change = total - prior.details["totals"][sid]["total"]
extra["delta"] = {
"baseline_record": prior.id,
"value": change,
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
}
extra["interpretation"] = interpretation.scale_interpretation(
sid,
extra["totals"],
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
previous=prior.details["totals"][sid]["total"] if prior else None,
policy=policy,
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
)
if row.kind == "rus_audit_s" and not row.details.get("sex"):
return await manual_review(ctx, row, "alcohol_sex_unknown")
if row.kind == "rus_audit_s":
extra["interpretation"] = interpretation.scale_interpretation(
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
)
if extra["totals"]["rus_audit"].get("zone") == 4:
extra["parallel_routes"] = ["narcologist"]
retriage = extra.get("delta", {}).get("retriage", False)
await service.alert(
ctx.session,
ctx.token,
row.id + ":followup",
"followup_retriage_required" if retriage else "followup_review",
ctx.now,
level=AlertLevel.HIGH if retriage or extra.get("route") == "priority_psychiatrist" else AlertLevel.MODERATE,
params={"record_id": row.id, **extra},
)
await service.finish(ctx, row, "complete", extra=extra)
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
# The risk questions open the evening diary; continue straight into it.
step = await start(ctx, "diary_evening")
step.replies.insert(0, Reply(text=shown, kind="info"))
return step
from app.dialog.flows.core import menu_reply
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
# Diary flags that bring a full scale forward («Этап 2», p.4).
DIARY_SCALE_TASKS = {
"phq9_due_mood": "phq9",
"phq9_due_depression": "phq9",
"pcl5_due_ptsd": "pcl5",
"rus_audit_due_alcohol": "rus_audit_s",
}
async def apply_modules(ctx, modules):
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
(diagnosis from ЕМИАС) are kept and only extended."""
profile = await ctx.profile()
if profile is None:
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
return
current = list(profile.modules or [])
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
def local_day(ctx):
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
async def done_today(ctx, kind):
day = local_day(ctx)
return any(
r.kind == kind and r.day == day and r.status == "complete"
for r in await service.records(ctx.session, ctx.token)
)
async def start(ctx, kind="intake", task=None):
if not allowed(ctx) or crisis_open(ctx.data):
if task:
return Step()
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")])
existing = await service.records(ctx.session, ctx.token)
active = next((r for r in reversed(existing) if r.status in ("active", "paused")), None)
if active:
if task and task.record_id != active.id:
return Step()
if (
active.policy_version != load_policy().version
or active.version != content()["version"]
or active.details.get("runtime_version") != load_runtime().version
):
return await manual_review(ctx, active, "questionnaire_version_changed")
active.status = "active"
return await advance(ctx, active)
if ctx.data.get("intake_review_required") and kind != "cssrs_since_contact":
if task:
return Step()
return Step(replies=[Reply(text=texts()["review"], kind="info")])
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
row = await service.create_record(ctx, kind, task=task)
return await advance(ctx, row)
@on_enter("intake.start")
async def enter(ctx):
return await start(ctx)
@trigger("button:intake:start")
@trigger("button:intake:resume")
async def start_button(ctx, event):
return await start(ctx)
@trigger("button:menu:checkin", replace=True)
@trigger("system:daily_checkin", replace=True)
async def checkin(ctx, event):
if not intake_enabled():
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
if event.params.get("questionnaire_task"):
task = await ctx.session.get(QuestionnaireTask, event.params["questionnaire_task"])
if task is None or task.token != ctx.token or task.status not in ("pending", "active"):
return Step()
return await start(ctx, task.kind, task)
existing = await service.records(ctx.session, ctx.token)
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
return await start(ctx)
return await next_diary(ctx)
async def next_diary(ctx):
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
day = local_day(ctx)
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
for kind in DIARY_KINDS:
if kind in done:
continue
profile = await ctx.profile()
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
return await start(ctx, "cssrs_since_contact", risk_task)
return await start(ctx, kind)
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
@trigger("button:diary:next")
async def diary_button(ctx, event):
return await next_diary(ctx)
@state("intake")
async def collect(ctx, event: Event):
if not allowed(ctx):
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")], next_state="idle")
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
if row is None or row.token != ctx.token or row.status != "active":
return Step(next_state="idle")
if (
row.policy_version != load_policy().version
or row.version != content()["version"]
or row.details.get("runtime_version") != load_runtime().version
):
return await manual_review(ctx, row, "questionnaire_version_changed")
if event.type == "system":
task_id = event.params.get("questionnaire_task")
if task_id and row.details.get("task_id") == task_id:
return await advance(ctx, row)
return Step()
if event.button == "intake:pause":
row.status = "paused"
return Step(
replies=[Reply(text=texts()["paused"], buttons=[[Button(label=texts()["resume"], data="intake:resume")]])],
next_state="idle",
)
q = next_question(row)
if q is None:
return await advance(ctx, row)
prefix = f"iq:{row.id[:12]}:{q.id}:"
value = None
valid = False
if event.type == "button" and (event.button or "").startswith(prefix):
index = event.button[len(prefix) :]
if index == "skip" and q.optional:
valid = True
elif index.isdigit() and int(index) < len(q.options):
value, valid = q.options[int(index)][0], True
elif event.type == "text" and q.numeric:
try:
value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= q.maximum
except ValueError:
pass
elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True
if not valid:
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now)
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
return await advance(ctx, row)
+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")
+477
View File
@@ -0,0 +1,477 @@
"""Medication flow (#37, case scenario «управление приёмом лекарств»). Module-agnostic: every patient with a
prescription gets it, whatever the clinical module.
system:med_reminder {dose_id, drug, dose}
──▶ «Принял» → dose taken, check-in med_taken = true
──▶ «Пропущу» → dose skipped, med_taken = false ──▶ reason:
забыл / побочки (→ side effects) / решил бросить (quit_intent) / другое
──▶ «Есть побочные эффекты» → code buttons (тошнота, бессонница, …) or «Другое» → free text
→ check-in side_effects (+ dose.side_effects) → «А препарат сегодня приняли?»
menu «Вопрос о лекарстве" ──▶ meds.question (free text) ──▶ med_qa (instruction excerpt) or «уточните у врача»
rule action med_info (actions.py hook) ──▶ instruction excerpt for the rule topic + fixed notice
Everything the patient answers is written to the clinical DB (`doses`, `daily_checkins`) and the step asks the
engine to run monitoring (`notify_monitoring`): the rules (lane B, ADR-007) decide alerts and further actions
(quit intent → delayed-onset explanation, missed doses → caring contact). Nothing here decides risk.
Reminder buttons carry the dose they refer to (#56): "med:taken:<ref>", "med:skip:<ref>",
"med:side_effects:<ref>" and the follow-up buttons likewise ("med:se:<code>:<ref>", "med:skip_reason:<r>:<ref>",
"med:dose:yes:<ref>"), matched by prefix triggers. `<ref>` is the `doses.id` of the reminder or, when there is
no dose row (prescription only known to the МИС), the reminder's ISO date. A button of an earlier day's dose is
stale (friendly note, nothing recorded); so are the old payload-less buttons ("med:taken") of earlier releases.
Check-in answers without a button (daily.med step, free text) go to today's planned dose, if any.
"""
from datetime import date, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from app.config import get_settings
from app.core.privacy import scrub
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DailyCheckin, Dose
from app.db.clinical import Prescription as PrescriptionRow
from app.dialog.actions import register_med_info_handler
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations
from app.llm import agent
from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
# A client sends this button when the person submits a free-text step without writing anything.
NO_COMMENT = "text:skip"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class ReminderTexts(_Strict):
text: str
text_no_drug: str
taken: str
skip: str
side_effects: str
class SkipTexts(_Strict):
question: str
reasons: dict[str, str]
ack: dict[str, str]
class SideEffectTexts(_Strict):
question: str
options: dict[str, str]
questions: dict[str, str]
describe: str
ack: str
repeat: str
ask_dose: str
dose_yes: str
dose_no: str
class CheckinMedTexts(_Strict):
question: str
question_no_drug: str
yes: str
no: str
later: str
class QuestionTexts(_Strict):
prompt: str
no_prescription: str
class MedInfoTopic(_Strict):
section: str # med_qa section code (side_effects | onset | alcohol | ...)
question: str
lead: str = ""
fallback: str
notice: str = ""
class MedsTexts(_Strict):
status: str
reminder: ReminderTexts
taken_ack: str
stale: str
skip: SkipTexts
side_effects: SideEffectTexts
checkin: CheckinMedTexts
question: QuestionTexts
med_info: dict[str, MedInfoTopic]
def meds_texts_path() -> Path:
return get_settings().config_dir / "texts" / "meds.yaml"
@lru_cache
def load_meds_texts(path: Path | None = None) -> MedsTexts:
path = path or meds_texts_path()
return MedsTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- data helpers
class Rx(BaseModel):
"""Active prescription as the dialog needs it (drug INN + dose exactly as the doctor wrote them)."""
id: int | None
drug: str
dose: str
async def current_prescription(ctx: Ctx) -> Rx | None:
"""Latest prescription active today: clinical DB first, then the МИС integration (demo: in-memory)."""
today = ctx.now.date()
rows = await ctx.session.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == ctx.token).order_by(PrescriptionRow.id.desc())
)
for row in rows:
if (row.start_date is None or row.start_date <= today) and (row.end_date is None or row.end_date >= today):
return Rx(id=row.id, drug=row.drug, dose=row.dose)
for p in await integrations.prescriptions.get_prescriptions(ctx.token):
if (p.start_date is None or p.start_date <= today) and (p.end_date is None or p.end_date >= today):
return Rx(id=None, drug=p.drug, dose=p.dose)
return None
async def checkin_of(ctx: Ctx, day: date) -> DailyCheckin | None:
return await ctx.session.scalar(
select(DailyCheckin).where(DailyCheckin.token == ctx.token, DailyCheckin.day == day)
)
async def save_checkin(ctx: Ctx, **fields: Any) -> DailyCheckin:
return await repo.upsert_checkin(ctx.session, token=ctx.token, day=ctx.now.date(), now=ctx.now, **fields)
def dose_ref(dose_id: int | None, day: date) -> str:
"""Button payload naming a dose: its row id, or the reminder day when there is no row."""
return str(dose_id) if dose_id is not None else day.isoformat()
async def todays_dose(ctx: Ctx) -> Dose | None:
"""Latest dose row planned for today (local day of `ctx.now`) — target of answers given without a button."""
rows = await ctx.session.scalars(select(Dose).where(Dose.token == ctx.token).order_by(Dose.id.desc()).limit(20))
today = ctx.now.date()
return next((r for r in rows if r.planned_at.astimezone(ctx.now.tzinfo).date() == today), None)
async def resolve_ref(ctx: Ctx, ref: str | None) -> tuple[bool, Dose | None]:
"""(fresh, dose row) for a button payload. Not fresh: missing/foreign/unknown ref or a dose of another day."""
if not ref:
return False, None
if ref.isdigit():
row = await ctx.session.get(Dose, int(ref))
if row is None or row.token != ctx.token:
return False, None
return row.planned_at.astimezone(ctx.now.tzinfo).date() == ctx.now.date(), row
try:
return date.fromisoformat(ref) == ctx.now.date(), None
except ValueError:
return False, None
async def _dose_row(ctx: Ctx, ref: str | None) -> Dose | None:
if ref is None:
return await todays_dose(ctx)
return (await resolve_ref(ctx, ref))[1]
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None, ref: str | None = None) -> None:
"""Dose answer → `doses` row (`ref` from the button, else today's) + `daily_checkins.med_taken` (rules read
both)."""
row = await _dose_row(ctx, ref)
if row is not None:
row.status = status or ("taken" if taken else "skipped")
row.answered_at = ctx.now
await save_checkin(ctx, med_taken=taken)
async def record_side_effects(ctx: Ctx, codes: list[str], *, ref: str | None = None) -> None:
"""Merge side-effect codes into today's check-in (rule `side_effect_3d` counts days with any code)."""
today = await checkin_of(ctx, ctx.now.date())
merged = list(dict.fromkeys([*(today.side_effects if today and today.side_effects else []), *codes]))
await save_checkin(ctx, side_effects=merged)
row = await _dose_row(ctx, ref)
if row is not None:
row.side_effects = ",".join(c for c in dict.fromkeys([*(row.side_effects or "").split(","), *codes]) if c)
async def side_effect_reply(ctx: Ctx, codes: list[str]) -> Reply:
"""Reaction to newly reported side effects.
Same effect as yesterday → short support (the instruction was already shown; persistence over 3 days is the
rules' business). New effect with a known instruction question → the excerpt of the drug instruction
(med_qa, section side_effects) — never generated advice. Otherwise a plain acknowledgement.
"""
t = load_meds_texts().side_effects
yesterday = await checkin_of(ctx, ctx.now.date() - timedelta(days=1))
if yesterday is not None and set(codes) & set(yesterday.side_effects or []):
return Reply(text=t.repeat, kind="info")
rx = await current_prescription(ctx)
for code in codes:
if code in t.questions and rx is not None:
answer = await med_qa(rx.drug, scrub(t.questions[code]), topic="side_effects")
if answer is not None:
return Reply(text=answer.text, kind="info")
return Reply(text=t.ack, kind="info")
async def leave_to_idle(ctx: Ctx, event: Event, data: dict[str, Any] | None = None) -> Step:
"""Abandon the current sub-flow and let the idle state handle the event (menu button, system event...)."""
step = Step(next_state="idle", data=data or {})
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
async def run_trigger_inline(ctx: Ctx, key: str, event: Event) -> Step | None:
"""Run an idle trigger from inside another flow (e.g. a reminder button pressed during the check-in)."""
handler = get_trigger(key)
return await handler(ctx, event) if handler is not None else None
# ------------------------------------------------------------------------------------------- reminder
def _reminder_buttons(ref: str) -> list[list[Button]]:
t = load_meds_texts().reminder
return [
[Button(label=t.taken, data=f"med:taken:{ref}")],
[Button(label=t.skip, data=f"med:skip:{ref}")],
[Button(label=t.side_effects, data=f"med:side_effects:{ref}")],
]
@trigger("system:med_reminder", replace=True)
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().reminder
rx = await current_prescription(ctx)
drug = str(event.params.get("drug") or (rx.drug if rx else ""))
dose = str(event.params.get("dose") or (rx.dose if rx else ""))
try:
dose_id: int | None = int(event.params["dose_id"]) # type: ignore[call-overload]
except (KeyError, TypeError, ValueError):
dose_id = None
if dose_id is None and rx is not None and rx.id is not None:
# No scheduler row (demo console without #13): create the planned dose so the answer has a record.
row = Dose(token=ctx.token, prescription_id=rx.id, planned_at=ctx.now, status="pending")
ctx.session.add(row)
await ctx.session.flush()
dose_id = row.id
text = t.text.format(drug=drug, dose=dose).replace(" ", " ") if drug else t.text_no_drug
reply = Reply(text=text, buttons=_reminder_buttons(dose_ref(dose_id, ctx.now.date())), kind="question")
return Step(replies=[reply], data={"med_pending": None}) # key of earlier releases: the button carries it now
def _stale_reply() -> Step:
return Step(replies=[Reply(text=load_meds_texts().stale)])
async def _legacy(ctx: Ctx, event: Event) -> Step:
"""Payload-less medication button of an earlier release: the dose is unknown → treat as stale."""
return _stale_reply()
def _with_dose(fn: Any) -> Any:
"""Prefix-trigger handler wrapper: resolve the dose payload, answer stale buttons without recording."""
async def handler(ctx: Ctx, event: Event) -> Step:
ref = trigger_payload(event)
fresh, _row = await resolve_ref(ctx, ref)
if not fresh:
return _stale_reply()
return await fn(ctx, event, ref)
return handler
def med_trigger(name: str) -> Any:
"""Register `button:med:<name>:*` (with the dose payload) plus the legacy payload-less key as stale."""
def deco(fn: Any) -> Any:
trigger(f"button:med:{name}:*")(_with_dose(fn))
trigger(f"button:med:{name}")(_legacy)
return fn
return deco
@med_trigger("taken")
async def _taken(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, True, ref=ref)
return Step(replies=[Reply(text=load_meds_texts().taken_ack)], notify_monitoring=True)
@med_trigger("skip")
async def _skip(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, False, status="skipped", ref=ref)
t = load_meds_texts().skip
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}:{ref}")] for r in SKIP_REASONS]
return Step(replies=[Reply(text=t.question, buttons=buttons, kind="question")], notify_monitoring=True)
def _side_effect_question(ref: str) -> Reply:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.options[c], data=f"med:se:{c}:{ref}")] for c in SIDE_EFFECT_CODES]
return Reply(text=t.question, buttons=buttons, kind="question")
def _skip_reason(reason: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if reason == "side_effects":
return Step(replies=[_side_effect_question(ref)])
if reason == "quit":
# «Решил бросить» is the same signal as "брошу" in free text: rule `quit_intent` reacts (alert +
# delayed-onset explanation through the med_info action).
await save_checkin(ctx, quit_intent=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack["quit"])], notify_monitoring=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack[reason])])
return handler
for _reason in SKIP_REASONS:
med_trigger(f"skip_reason:{_reason}")(_skip_reason(_reason))
@med_trigger("side_effects")
async def _side_effects(ctx: Ctx, event: Event, ref: str) -> Step:
return Step(replies=[_side_effect_question(ref)])
async def _after_side_effects(ctx: Ctx, codes: list[str], ref: str | None) -> Step:
if ref is None:
row = await todays_dose(ctx)
ref = dose_ref(row.id if row is not None else None, ctx.now.date())
await record_side_effects(ctx, codes, ref=ref)
replies = [await side_effect_reply(ctx, codes)]
today = await checkin_of(ctx, ctx.now.date())
if (today is None or today.med_taken is None) and await current_prescription(ctx) is not None:
t = load_meds_texts().side_effects
buttons = [
[Button(label=t.dose_yes, data=f"med:dose:yes:{ref}")],
[Button(label=t.dose_no, data=f"med:dose:no:{ref}")],
]
replies.append(Reply(text=t.ask_dose, buttons=buttons, kind="question"))
return Step(replies=replies, next_state="idle", data={"med_se_ref": None}, notify_monitoring=True)
def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other":
return Step(
# kind="question": the engine waits for free text here (clients show an input, optional).
replies=[Reply(text=load_meds_texts().side_effects.describe, kind="question")],
next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref},
)
return await _after_side_effects(ctx, [code], ref)
return handler
for _code in SIDE_EFFECT_CODES:
med_trigger(f"se:{_code}")(_side_effect_code(_code))
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
ref = ctx.data.get("med_se_ref")
if event.type == "button" and event.button == NO_COMMENT:
# The description is optional: «other» side effect without words.
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
def _dose_answer(taken: bool) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, taken, status=None if taken else "missed", ref=ref)
return Step(
replies=[Reply(text=load_meds_texts().taken_ack if taken else load_meds_texts().skip.ack["other"])],
notify_monitoring=True,
)
return handler
med_trigger("dose:yes")(_dose_answer(True))
med_trigger("dose:no")(_dose_answer(False))
# Free-text waiting states of this flow: a long-forgotten question must not swallow tomorrow's message.
abandonable("meds")
# ------------------------------------------------------------------------------------------- question menu
@trigger("button:menu:med_question", replace=True)
async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
if DOSE_QUESTION.search(ctx.text.lower()) is None:
# Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only.
smart = await agent.answer_chat(ctx.token, ctx.text)
if smart is not None:
return agent.render(smart)
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
text = await answer_question(ctx, await current_prescription(ctx))
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# ------------------------------------------------------------------------------------------- rule actions
async def med_info_reply(ctx: Ctx, action: Action) -> Reply | None:
"""`med_info` action of a rule (quit_intent → onset, alcohol_3d → alcohol): instruction excerpt framed by
fixed texts. The rule already decided to say it; this only picks the words. None → engine template."""
spec = load_meds_texts().med_info.get(str(action.params.get("topic")))
if spec is None:
return None
rx = await current_prescription(ctx)
answer = await med_qa(rx.drug, scrub(spec.question), topic=spec.section) if rx is not None else None
body = answer.text if answer is not None else spec.fallback
return Reply(text="\n\n".join(p for p in (spec.lead, body, spec.notice) if p), kind="info")
register_med_info_handler(med_info_reply)
+470
View File
@@ -0,0 +1,470 @@
"""Daily check-in and scheduled-scale intros (#37, ADR-002, CLINICAL_MODULES §1–2, case «мониторинг после
обращения»).
Check-in = core (mood 0–10, dose taken) + the module question (veteran: sleep hours + nightmares; alcohol for
veterans and for anyone with a positive AUDIT-C) + free text:
system:daily_checkin / menu «Как я сегодня»
──▶ daily.mood buttons 0–10 (a bare number is accepted too)
──▶ daily.med only with a prescription and no dose answer today (the reminder may have got it)
──▶ daily.sleep <4 / 4–6 / 6–8 / >8 h ┐ module veteran
──▶ daily.nightmares yes / no ┘
──▶ daily.alcohol none / 1–2 / more (veteran or AUDIT-C positive)
──▶ daily.text «Как вы? Пару слов» (skip button) → extract_checkin on the SCRUBBED text
──▶ [daily.alcohol_amount] alcohol mentioned in the text but not asked by button → amount buttons
──▶ idle upsert done, `notify_monitoring` → rules (lane B) → dialog actions
Data algorithm (medical data, jury requirement):
* Every button answer is written to `daily_checkins` immediately (`repo.upsert_checkin` merges non-None
fields), so an interrupted check-in (crisis, the patient walking away) still leaves today's mood on record —
the "mood ≤ 3 three days in a row" rule must not miss a day because the free text was never written.
* Button answers are the patient's own and always win over the LLM: from the free text the LLM may only add
side effects, the quit intent and fields that were not asked by buttons today.
* The LLM never decides anything here: alerts and follow-ups (delayed-onset explanation, unscheduled PHQ-9,
alcohol interaction, visit offer) come from the rules through `MonitoringService` (ADR-007).
* A free-text message classified POSSIBLE by the safety layer is taken over by the engine (C-SSRS). When the
screen returns to `daily.text` the message counts as the answer: the check-in is closed, not asked again.
* A check-in left unfinished is dropped on the next event of a later day, or after
`settings.abandon_after_hours` of silence (engine abandon timeout, #56) — answers given so far stay stored.
Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc), registered with
`register_scale_intro` so the engine's `scale_due` / rule-triggered sessions show them.
"""
import re
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from app.config import get_settings
from app.core.intake_runtime import intake_enabled
from app.core.rules import load_rules
from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows import intake
from app.dialog.flows.core import menu_reply
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
from app.dialog.flows.meds import (
answer_question,
checkin_of,
current_prescription,
leave_to_idle,
load_meds_texts,
record_dose,
record_side_effects,
run_trigger_inline,
save_checkin,
side_effect_reply,
)
from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger
from app.dialog.scale_session import register_scale_intro
PREFIX = "daily"
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
ALCOHOL_AMOUNT = "alcohol_amount"
SLEEP_BUCKETS = ("<4", "4-6", "6-8", ">8")
ALCOHOL_BUCKETS = ("none", "1-2", "more")
_NUMBER = re.compile(r"^\s*(\d{1,2})\s*$")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class DailySettings(_Strict):
sleep_modules: list[str]
alcohol_daily_modules: list[str]
intro_scales: list[str]
class Question(_Strict):
question: str
class OptionsQuestion(_Strict):
question: str
options: dict[str, str]
class YesNoQuestion(_Strict):
question: str
yes: str
no: str
class AlcoholQuestion(_Strict):
question: str
clarify: str
options: dict[str, str]
class TextQuestion(_Strict):
question: str
skip: str
class CheckinTexts(_Strict):
start: str
mood: Question
sleep: OptionsQuestion
nightmares: YesNoQuestion
alcohol: AlcoholQuestion
text: TextQuestion
buttons_hint: str
done: str
already_done: str
class VisitTexts(_Strict):
requested: str
class ScaleIntros(_Strict):
scheduled: dict[str, str]
unscheduled: dict[str, str]
class DailyTexts(_Strict):
status: str
settings: DailySettings
checkin: CheckinTexts
visit: VisitTexts
scale_intros: ScaleIntros
def daily_texts_path() -> Path:
return get_settings().config_dir / "texts" / "veteran_daily.yaml"
@lru_cache
def load_daily_texts(path: Path | None = None) -> DailyTexts:
path = path or daily_texts_path()
return DailyTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- step logic
def _today(ctx: Ctx) -> str:
return ctx.now.date().isoformat()
def _ck(ctx: Ctx) -> dict[str, Any]:
return dict(ctx.data.get("checkin") or {})
async def _modules(ctx: Ctx) -> set[str]:
profile = await ctx.profile()
return set(profile.modules or []) if profile is not None else set()
async def _auditc_positive(ctx: Ctx) -> bool:
"""Latest AUDIT-C at or above the sex-specific cut-off of rule `auditc_positive` (one threshold, one place)."""
results = await repo.list_scale_results(ctx.session, ctx.token, "auditc")
if not results:
return False
rule = next((r for r in load_rules().rules if r.id == "auditc_positive"), None)
if rule is None:
return False
profile = await ctx.profile()
key = "threshold_f" if profile is not None and profile.sex == "f" else "threshold_m"
return results[-1].total >= int(rule.params[key])
async def _applicable(ctx: Ctx, step: str) -> bool:
settings = load_daily_texts().settings
if step in ("mood", "text"):
return True
if step == "med":
today = await checkin_of(ctx, ctx.now.date())
return (today is None or today.med_taken is None) and await current_prescription(ctx) is not None
modules = await _modules(ctx)
if step in ("sleep", "nightmares"):
return bool(modules & set(settings.sleep_modules))
if step == "alcohol":
return bool(modules & set(settings.alcohol_daily_modules)) or await _auditc_positive(ctx)
return False
async def _next_step(ctx: Ctx, after: str | None) -> str:
rest = STEPS if after is None else STEPS[STEPS.index(after) + 1 :]
for step in rest:
if await _applicable(ctx, step):
return step
return "text"
def _buttons(step: str, options: list[tuple[str, str]]) -> list[list[Button]]:
return [[Button(label=label, data=f"{PREFIX}:{step}:{value}")] for value, label in options]
async def _question(ctx: Ctx, step: str, note: str = "") -> Reply:
t = load_daily_texts().checkin
buttons: list[list[Button]]
if step == "mood":
text = t.mood.question
# Two rows (0–5, 6–10): 11 buttons in one column are hard to use on a phone.
buttons = [
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6)],
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6, 11)],
]
elif step == "med":
m = load_meds_texts().checkin
rx = await current_prescription(ctx)
text = m.question.format(drug=rx.drug) if rx is not None else m.question_no_drug
buttons = _buttons(step, [("yes", m.yes), ("no", m.no), ("later", m.later)])
elif step == "sleep":
text = t.sleep.question
buttons = _buttons(step, [(b, t.sleep.options[b]) for b in SLEEP_BUCKETS])
elif step == "nightmares":
text = t.nightmares.question
buttons = _buttons(step, [("yes", t.nightmares.yes), ("no", t.nightmares.no)])
elif step in ("alcohol", ALCOHOL_AMOUNT):
text = t.alcohol.question if step == "alcohol" else t.alcohol.clarify
buttons = _buttons(step, [(b, t.alcohol.options[b]) for b in ALCOHOL_BUCKETS])
else:
text = t.text.question
buttons = _buttons("text", [("skip", t.text.skip)])
return Reply(text="\n\n".join(p for p in (note, text) if p), buttons=buttons, kind="question")
async def start_checkin(ctx: Ctx, *, from_menu: bool) -> Step:
if ctx.data.get("checkin_done") == _today(ctx):
# The scheduler may fire twice; the menu button gets a friendly note instead of a second check-in.
return Step(replies=[menu_reply(ctx, load_daily_texts().checkin.already_done)]) if from_menu else Step()
first = await _next_step(ctx, None)
ck = {"day": _today(ctx), "answered": [], "started_at": ctx.now.isoformat()}
return Step(
replies=[Reply(text=load_daily_texts().checkin.start)],
next_state=f"{PREFIX}.{first}",
data={"checkin": ck},
)
def _finish(ctx: Ctx, replies: list[Reply] | None = None) -> Step:
done = Reply(text=load_daily_texts().checkin.done)
return Step(
replies=[done, *(replies or [])],
next_state="idle",
data={"checkin": None, "checkin_done": _today(ctx)},
notify_monitoring=True,
)
def _consumed_by_safety(ctx: Ctx, ck: dict[str, Any]) -> bool:
"""The free-text answer was taken over by the safety layer (C-SSRS screen) after the question was asked."""
asked = ck.get("text_asked_at")
if not asked:
return False
for event in reversed(ctx.data.get("safety_events") or []):
if event.get("state") == f"{PREFIX}.text" and datetime.fromisoformat(event["ts"]) >= datetime.fromisoformat(
asked
):
return True
return False
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
ck = _ck(ctx)
if not ck or ck.get("day") != _today(ctx):
return Step(next_state="idle", data={"checkin": None})
if step == "text":
if _consumed_by_safety(ctx, ck):
return _finish(ctx)
return Step(
replies=[await _question(ctx, step)], data={"checkin": {**ck, "text_asked_at": ctx.now.isoformat()}}
)
return Step(replies=[await _question(ctx, step)])
async def _advance(ctx: Ctx, step: str, ck: dict[str, Any]) -> Step:
ck = {**ck, "answered": [*ck.get("answered", []), step]}
if step == ALCOHOL_AMOUNT:
return _finish(ctx)
nxt = await _next_step(ctx, step)
return Step(next_state=f"{PREFIX}.{nxt}", data={"checkin": ck})
async def _record_button(ctx: Ctx, step: str, value: str) -> bool:
"""Store one button answer right away. False = value not valid for this step (stale/forged button)."""
if step == "mood" and value.isdigit() and 0 <= int(value) <= 10:
await save_checkin(ctx, mood=int(value))
elif step == "med" and value in ("yes", "no", "later"):
if value != "later": # «Ещё нет» is not a missed dose: nothing is recorded yet
await record_dose(ctx, value == "yes", status=None if value == "yes" else "missed")
elif step == "sleep" and value in SLEEP_BUCKETS:
await save_checkin(ctx, sleep_bucket=value)
elif step == "nightmares" and value in ("yes", "no"):
await save_checkin(ctx, nightmares=value == "yes")
elif step in ("alcohol", ALCOHOL_AMOUNT) and value in ALCOHOL_BUCKETS:
await save_checkin(ctx, alcohol_bucket=value)
else:
return False
return True
async def _free_text(ctx: Ctx, ck: dict[str, Any]) -> Step:
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
answered = set(ck.get("answered", []))
fields: dict[str, Any] = {"free_text_scrubbed": ctx.text}
replies: list[Reply] = []
clarify_alcohol = False
if facts is not None:
if facts.quit_intent:
fields["quit_intent"] = True
# Buttons win: the LLM only fills what was not asked today.
if "med" not in answered and facts.med_taken is not None:
today = await checkin_of(ctx, ctx.now.date())
if today is None or today.med_taken is None:
fields["med_taken"] = facts.med_taken
if "sleep" not in answered and facts.sleep_hours_bucket is not None:
fields["sleep_bucket"] = facts.sleep_hours_bucket
if "nightmares" not in answered and facts.nightmares is not None:
fields["nightmares"] = facts.nightmares
if "alcohol" not in answered and facts.alcohol is not None:
fields["alcohol_bucket"] = facts.alcohol # provisional; the amount buttons below refine it
clarify_alcohol = facts.alcohol != "none"
if facts.side_effects:
await record_side_effects(ctx, [str(c) for c in facts.side_effects])
# One topic per message: when the text also carries a quit intent or alcohol, the rules answer
# those (delayed onset / interaction); the side-effect excerpt would only bury that message.
if not facts.quit_intent and facts.alcohol in (None, "none"):
replies.append(await side_effect_reply(ctx, [str(c) for c in facts.side_effects]))
await save_checkin(ctx, **fields)
if clarify_alcohol:
return Step(replies=replies, next_state=f"{PREFIX}.{ALCOHOL_AMOUNT}", data={"checkin": ck})
return _finish(ctx, replies)
abandonable(PREFIX)
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
ck = _ck(ctx)
if not ck or ck.get("day") != _today(ctx):
# Unfinished check-in from an earlier day: drop it and handle the event as idle would.
return await leave_to_idle(ctx, event, {"checkin": None})
if event.type == "button" and event.button:
parts = event.button.split(":")
if len(parts) == 3 and parts[0] == PREFIX and parts[1] == step:
if step == "text" and parts[2] == "skip":
return _finish(ctx)
if await _record_button(ctx, step, parts[2]):
return await _advance(ctx, step, ck)
if parts[0] == "med":
# A reminder button pressed mid check-in: answer it, then repeat the current question.
inline = await run_trigger_inline(ctx, f"button:{event.button}", event)
if inline is not None:
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
if parts[0] in (PREFIX, "scale"):
return Step(replies=[await _question(ctx, step)]) # stale button of an earlier question
return await leave_to_idle(ctx, event, {"checkin": None})
if event.type == "system" and event.system:
if event.system == "daily_checkin":
return Step(replies=[await _question(ctx, step)])
inline = await run_trigger_inline(ctx, f"system:{event.system}", event)
if inline is None:
return Step()
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
if event.type == "text" and ctx.text is not None:
if step == "text":
return await _free_text(ctx, ck)
number = _NUMBER.match(ctx.text)
if step == "mood" and number and await _record_button(ctx, "mood", number.group(1)):
return await _advance(ctx, step, ck)
return Step(replies=[await _question(ctx, step, note=load_daily_texts().checkin.buttons_hint)])
return Step(replies=[await _question(ctx, step)])
# ------------------------------------------------------------------------------------------- idle triggers
@trigger("system:daily_checkin", replace=True)
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
if intake_enabled(): # the intake diary (#53, feature flag) replaces this check-in when released
return await intake.checkin(ctx, event)
return await start_checkin(ctx, from_menu=False)
@trigger("button:menu:checkin", replace=True)
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
if intake_enabled():
return await intake.checkin(ctx, event)
return await start_checkin(ctx, from_menu=True)
@trigger("button:menu:visit", replace=True)
async def _visit(ctx: Ctx, event: Event) -> Step:
# «Записаться раньше» (rule action suggest_visit): no online booking yet — the request is a fact for lane B
# (#38) and the dashboard, like `contact_requests`. No alert is created here (ADR-007).
requests = [*(ctx.data.get("visit_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
return Step(
replies=[Reply(text=load_daily_texts().visit.requested)],
data={"visit_requests": requests},
notify_monitoring=True,
)
@trigger("text:idle", replace=True)
async def _idle_text(ctx: Ctx, event: Event) -> Step:
"""Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol
→ today's check-in (the rules react); another question → clinical guidelines (knowledge base); otherwise
the neutral support phrase with the menu."""
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None:
text = await knowledge_answer(ctx, explicit=False) if looks_like_question(ctx.text) else None
return Step(replies=[menu_reply(ctx, text or await ctx.gateway.phrase("support"))])
fields: dict[str, Any] = {}
if facts.quit_intent:
fields["quit_intent"] = True
if facts.alcohol is not None and facts.alcohol != "none":
today = await checkin_of(ctx, ctx.now.date())
if today is None or today.alcohol_bucket in (None, "none"):
fields["alcohol_bucket"] = facts.alcohol
text: str | None = None
if facts.side_effects:
codes = [str(c) for c in facts.side_effects]
await record_side_effects(ctx, codes)
if not facts.quit_intent:
text = (await side_effect_reply(ctx, codes)).text
if fields:
await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None:
text = await ctx.gateway.phrase("support")
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
# ------------------------------------------------------------------------------------------- scale intros
def _scale_intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
"""Friendly intro of a scheduled (or rule-triggered) scale. Onboarding sessions (#36) have their own texts."""
if ".onboarding" in return_state:
return None
intros = load_daily_texts().scale_intros
return (intros.scheduled if scheduled else intros.unscheduled).get(scale_id)
for _scale_id in load_daily_texts().settings.intro_scales:
register_scale_intro(_scale_id, _scale_intro)
+458
View File
@@ -0,0 +1,458 @@
"""Veteran start screening (issue #36, CLINICAL_MODULES §1, ADR-001/002/007/010).
Entered by the core flow after consent + age gate when the profile has module `veteran`:
veteran.onboarding.start intro «что нормально после возвращения» + FAQ ──«Начнём»──▶
veteran.onboarding.exposure trauma exposure (yes / no / decline)
yes ──▶ scale.pcptsd5 ──▶ ┐
no / decline ───────────▶ ┤ (PC-PTSD-5 is not administered; nothing is stored for it)
veteran.onboarding.pcl5 ─┘──▶ scale.pcl5
veteran.onboarding.phq9 ──▶ scale.phq9 (item 9 buttons only; positive → engine crisis path)
veteran.onboarding.auditc sex from profile, asked if unknown ──▶ scale.auditc
veteran.onboarding.weapon weapon access (4 buttons) → facts; access + risk signal → safe-storage block
veteran.onboarding.result plain-language summary, modules enabled in profile → finish_onboarding()
Clinical decisions encoded here:
* PCL-5 is given to every veteran regardless of the exposure answer: the module's start set is PCL-5/PHQ-9/
AUDIT-C (CLINICAL_MODULES §1); PC-PTSD-5 is only a short entry screen and, by its own instructions, is asked
only after a "yes" to trauma exposure (`config/scales/pcptsd5.yaml: notes`).
* Module `depression` is enabled when the PHQ-9 total meets `config/modules.yaml:
veteran.enables_depression_if` (≥ 10). Scores and severity labels are never shown to the patient.
* Safe-storage block (means restriction, one of the best-evidenced suicide-prevention measures) is shown when
the patient reports access to a weapon AND there is a risk signal in this onboarding: a scale flag listed in
`settings.weapon_risk_flags` (PHQ-9 item 9 ≥ 1, C-SSRS, AUDIT-C positive), a safety event (POSSIBLE/ACUTE)
or a crisis opened since the onboarding began. Deterministic, no LLM (ADR-007).
Facts for lane B (`dialog_state.data["facts"]`, merged with whatever else is there):
trauma_exposure True | False | None (declined)
weapon_access True (self / others_or_safe) | False (none) | None (declined)
weapon_access_choice "none" | "self" | "others_or_safe" | "declined"
weapon_access_at ISO timestamp of the answer
weapon_safety_block True when the safe-storage block was shown
`PatientSnapshot.weapon_access` should be built from `facts["weapon_access"]`.
Crisis during onboarding: the engine moves to `crisis` and drops the running scale session. Progress is kept in
`data["veteran_onboarding"]` (`stage`, `since_id`); when the FSM later enters `idle` (doctor acknowledged the
crisis) the onboarding resumes at the stored stage, and scale stages whose result was already stored since the
onboarding began are skipped (`since_id` = last scale_results id before the start).
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
from app.core.modules import load_modules
from app.db import repo
from app.db.clinical import ScaleResult
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open
from app.dialog.flows.core import finish_onboarding, menu_reply
from app.dialog.fsm import Ctx, Step, on_enter, state, trigger
from app.dialog.scale_session import start_scale
MODULE = "veteran"
PREFIX = "veteran.onboarding"
START = f"{PREFIX}.start"
EXPOSURE = f"{PREFIX}.exposure"
PCL5 = f"{PREFIX}.pcl5"
PHQ9 = f"{PREFIX}.phq9"
AUDITC = f"{PREFIX}.auditc"
WEAPON = f"{PREFIX}.weapon"
RESULT = f"{PREFIX}.result"
BTN = "vonb" # button data namespace: "vonb:<question>:<choice>"
FAQ_KEYS = ("who", "unit", "licences")
# choice code → value of facts["weapon_access"]: storage with others still means the patient owns a weapon.
WEAPON_ACCESS: dict[str, bool | None] = {"none": False, "self": True, "others_or_safe": True, "declined": None}
EXPOSURE_VALUE: dict[str, bool | None] = {"yes": True, "no": False, "declined": None}
SEX_VALUE: dict[str, str | None] = {"m": "m", "f": "f", "declined": None}
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class OnbSettings(_Strict):
weapon_risk_flags: list[str] = Field(default_factory=list)
class IntroTexts(_Strict):
text: str
go: str
repeat: str
resume: str
class ExposureTexts(_Strict):
question: str
answer_yes: str
answer_no: str
decline: str
ack_yes: str
ack_other: str
class SexTexts(_Strict):
question: str
male: str
female: str
decline: str
class WeaponTexts(_Strict):
question: str
no_access: str
self: str
others_or_safe: str
decline: str
ack: str
safety_block_self: str
safety_block_stored: str
safety_block_unknown: str
class ResultTexts(_Strict):
intro: str
calm: str
load: str
ptsd: str
mood: str
alcohol: str
next: str
class FaqEntry(_Strict):
button: str
text: str
class FaqTexts(_Strict):
who: FaqEntry
unit: FaqEntry
licences: FaqEntry
class VeteranOnboardingTexts(_Strict):
status: str
settings: OnbSettings
intro: IntroTexts
buttons_hint: str
exposure: ExposureTexts
sex: SexTexts
weapon: WeaponTexts
result: ResultTexts
faq: FaqTexts
def texts_path() -> Path:
return get_settings().config_dir / "texts" / "veteran_onboarding.yaml"
@lru_cache
def load_texts(path: Path | None = None) -> VeteranOnboardingTexts:
path = path or texts_path()
return VeteranOnboardingTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- helpers
def _progress(ctx: Ctx) -> dict[str, Any]:
return dict(ctx.data.get("veteran_onboarding") or {})
def _at_stage(ctx: Ctx, stage: str) -> dict[str, Any]:
"""Data update remembering the current stage (resume point after a crisis)."""
return {"veteran_onboarding": {**_progress(ctx), "stage": stage}}
def _facts(ctx: Ctx, **updates: Any) -> dict[str, Any]:
return {"facts": {**(ctx.data.get("facts") or {}), **updates}}
def _faq_buttons() -> list[list[Button]]:
faq = load_texts().faq
return [[Button(label=getattr(faq, key).button, data=f"{BTN}:faq:{key}")] for key in FAQ_KEYS]
def _choice(event: Event, question: str, allowed: dict[str, Any]) -> str | None:
if event.type != "button" or not event.button:
return None
parts = event.button.split(":")
if len(parts) == 3 and parts[0] == BTN and parts[1] == question and parts[2] in allowed:
return parts[2]
return None
def _faq_key(event: Event) -> str | None:
if event.type == "button" and event.button and event.button.startswith(f"{BTN}:faq:"):
key = event.button.rsplit(":", 1)[1]
return key if key in FAQ_KEYS else None
return None
async def _results_since(ctx: Ctx) -> list[ScaleResult]:
since_id = int(_progress(ctx).get("since_id") or 0)
return [r for r in await repo.list_scale_results(ctx.session, ctx.token) if r.id > since_id]
async def _latest(ctx: Ctx, scale: str) -> ScaleResult | None:
rows = [r for r in await _results_since(ctx) if r.scale == scale]
return rows[-1] if rows else None
async def _risk_signals(ctx: Ctx) -> list[str]:
"""Deterministic risk signals collected during this onboarding (see module docstring)."""
risk_flags = set(load_texts().settings.weapon_risk_flags)
signals = sorted({f for r in await _results_since(ctx) for f in (r.flags or {}) if f in risk_flags})
started = str(_progress(ctx).get("started_at") or "")
events = [
e
for e in ctx.data.get("safety_events") or []
if str(e.get("ts", "")) >= started and e.get("level") in ("possible", "acute")
]
if events:
signals.append("safety_event")
opened_at = str((ctx.data.get("crisis") or {}).get("opened_at") or "")
if crisis_open(ctx.data) or (opened_at and opened_at >= started):
signals.append("crisis")
return signals
async def _scale_stage(ctx: Ctx, stage: str, scale: str, next_stage: str) -> Step:
"""Start `scale` unless its result is already stored in this onboarding (resume after a crisis)."""
if await _latest(ctx, scale) is not None:
return Step(next_state=next_stage, data=_at_stage(ctx, next_stage))
step = await start_scale(ctx, scale, return_state=next_stage, scheduled=True)
step.data.update(_at_stage(ctx, stage))
return step
# ------------------------------------------------------------------------------------------- start
def _intro_reply(text: str) -> Reply:
go = Button(label=load_texts().intro.go, data=f"{BTN}:go:yes")
return Reply(text=text, buttons=[[go], *_faq_buttons()], kind="question")
@on_enter(START)
async def _start_enter(ctx: Ctx) -> Step:
rows = await repo.list_scale_results(ctx.session, ctx.token)
progress = {
"stage": START,
"since_id": max((r.id for r in rows), default=0),
"started_at": ctx.now.isoformat(),
}
return Step(replies=[_intro_reply(load_texts().intro.text)], data={"veteran_onboarding": progress})
# ------------------------------------------------------------------------------------------- questions
def _exposure_reply(note: str = "") -> Reply:
t = load_texts().exposure
buttons = [
[Button(label=t.answer_yes, data=f"{BTN}:exposure:yes")],
[Button(label=t.answer_no, data=f"{BTN}:exposure:no")],
[Button(label=t.decline, data=f"{BTN}:exposure:declined")],
]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
def _sex_reply(note: str = "") -> Reply:
t = load_texts().sex
buttons = [
[Button(label=t.male, data=f"{BTN}:sex:m")],
[Button(label=t.female, data=f"{BTN}:sex:f")],
[Button(label=t.decline, data=f"{BTN}:sex:declined")],
]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
def _weapon_reply(note: str = "") -> Reply:
t = load_texts().weapon
buttons = [
[Button(label=t.no_access, data=f"{BTN}:weapon:none")],
[Button(label=t.self, data=f"{BTN}:weapon:self")],
[Button(label=t.others_or_safe, data=f"{BTN}:weapon:others_or_safe")],
[Button(label=t.decline, data=f"{BTN}:weapon:declined")],
]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
def _current_question(ctx: Ctx, note: str = "") -> Reply:
if ctx.state == EXPOSURE:
return _exposure_reply(note)
if ctx.state == AUDITC:
return _sex_reply(note)
if ctx.state == WEAPON:
return _weapon_reply(note)
t = load_texts().intro
return _intro_reply("\n\n".join(p for p in (note, t.repeat) if p))
@on_enter(EXPOSURE)
async def _exposure_enter(ctx: Ctx) -> Step:
return Step(replies=[_exposure_reply()], data=_at_stage(ctx, EXPOSURE))
@on_enter(PCL5)
async def _pcl5_enter(ctx: Ctx) -> Step:
return await _scale_stage(ctx, PCL5, "pcl5", PHQ9)
@on_enter(PHQ9)
async def _phq9_enter(ctx: Ctx) -> Step:
# Item 9 is buttons-only and a positive answer opens the crisis path inside the scale session (engine).
return await _scale_stage(ctx, PHQ9, "phq9", AUDITC)
@on_enter(AUDITC)
async def _auditc_enter(ctx: Ctx) -> Step:
if await _latest(ctx, "auditc") is not None:
return Step(next_state=WEAPON, data=_at_stage(ctx, WEAPON))
profile = await ctx.profile()
known = profile is not None and profile.sex in ("m", "f")
if known or _progress(ctx).get("sex_asked"):
# Unknown sex after "не хочу отвечать": AUDIT-C uses the more sensitive default threshold.
return await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
return Step(replies=[_sex_reply()], data=_at_stage(ctx, AUDITC))
@on_enter(WEAPON)
async def _weapon_enter(ctx: Ctx) -> Step:
return Step(replies=[_weapon_reply()], data=_at_stage(ctx, WEAPON))
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
"""Events in the onboarding's own question states (scales are handled by the scale session)."""
t = load_texts()
faq = _faq_key(event)
if faq is not None:
return Step(replies=[Reply(text=getattr(t.faq, faq).text), _current_question(ctx)])
if event.type == "system":
return Step() # reminders do not interrupt the screening
if ctx.state == START:
if _choice(event, "go", {"yes": True}):
return Step(next_state=EXPOSURE)
return Step(replies=[_current_question(ctx)])
if ctx.state == EXPOSURE and (code := _choice(event, "exposure", EXPOSURE_VALUE)):
exposed = EXPOSURE_VALUE[code]
step = Step(data=_facts(ctx, trauma_exposure=exposed))
if exposed:
step.replies.append(Reply(text=t.exposure.ack_yes))
nxt = await start_scale(ctx, "pcptsd5", return_state=PCL5, scheduled=True)
nxt.data.update(_at_stage(ctx, EXPOSURE))
return step.then(nxt)
step.replies.append(Reply(text=t.exposure.ack_other))
return step.then(Step(next_state=PCL5))
if ctx.state == AUDITC and (code := _choice(event, "sex", SEX_VALUE)):
sex = SEX_VALUE[code]
if sex is not None:
await repo.upsert_profile(ctx.session, token=ctx.token, sex=sex)
step = await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
step.data["veteran_onboarding"] = {**step.data["veteran_onboarding"], "sex_asked": True}
return step
if ctx.state == WEAPON and (code := _choice(event, "weapon", WEAPON_ACCESS)):
return await _weapon_answer(ctx, code)
return Step(replies=[_current_question(ctx, t.buttons_hint)])
async def _weapon_answer(ctx: Ctx, code: str) -> Step:
t = load_texts().weapon
access = WEAPON_ACCESS[code]
replies = [Reply(text=t.ack)]
show_block = access is not False and bool(await _risk_signals(ctx))
if show_block:
block = {"self": t.safety_block_self, "others_or_safe": t.safety_block_stored}.get(code, t.safety_block_unknown)
replies = [Reply(text=block, kind="safety_plan")]
data = _facts(
ctx,
weapon_access=access,
weapon_access_choice=code,
weapon_access_at=ctx.now.isoformat(),
weapon_safety_block=show_block,
)
# notify_monitoring: weapon access is a monitoring fact (lane B rule "доступ к оружию → блок безопасности").
return Step(replies=replies, next_state=RESULT, data=data, notify_monitoring=True)
# ------------------------------------------------------------------------------------------- result
def _meets(total: int, op: str, value: int) -> bool:
return total >= value if op == ">=" else total > value
@on_enter(RESULT)
async def _result_enter(ctx: Ctx) -> Step:
t = load_texts().result
pcl5 = await _latest(ctx, "pcl5")
auditc = await _latest(ctx, "auditc")
# Depression module: PHQ-9 total vs. the threshold from config/modules.yaml (veteran.enables_depression_if).
rule = load_modules().modules[MODULE].enables_depression_if
trigger_result = await _latest(ctx, rule.scale) if rule is not None else None
depression = bool(rule and trigger_result and _meets(trigger_result.total, rule.op, rule.value))
profile = await ctx.profile()
modules = list(profile.modules or []) if profile is not None else []
for module in (MODULE, "depression") if depression else (MODULE,):
if module not in modules:
modules.append(module)
await repo.upsert_profile(ctx.session, token=ctx.token, modules=modules)
lines = []
if pcl5 is not None and "pcl5_probable_ptsd" in (pcl5.flags or {}):
lines.append(t.ptsd)
if depression:
lines.append(t.mood)
if auditc is not None and "auditc_positive" in (auditc.flags or {}):
lines.append(t.alcohol)
body = [t.intro, t.load, *lines] if lines else [t.intro, t.calm]
summary = Reply(text="\n".join([*body, "", t.next]), buttons=_faq_buttons())
done = Step(
replies=[summary],
data={"veteran_onboarding": {**_progress(ctx), "stage": None, "done_at": ctx.now.isoformat()}},
notify_monitoring=True,
)
return done.then(await finish_onboarding(ctx, module=MODULE))
# ------------------------------------------------------------------------------------------- resume / FAQ
@on_enter("idle")
async def _resume_after_crisis(ctx: Ctx) -> Step:
"""Back in idle with an unfinished veteran onboarding (it was interrupted by a crisis) → continue it."""
stage = _progress(ctx).get("stage")
if not stage or not str(stage).startswith(PREFIX) or crisis_open(ctx.data) or ctx.data.get("consent") is not True:
return Step()
stage = EXPOSURE if stage == START else str(stage)
return Step(replies=[Reply(text=load_texts().intro.resume)], next_state=stage)
def _faq_trigger(key: str) -> None:
@trigger(f"button:{BTN}:faq:{key}")
async def _faq(ctx: Ctx, event: Event) -> Step:
return Step(replies=[menu_reply(ctx, getattr(load_texts().faq, key).text)])
for _key in FAQ_KEYS:
_faq_trigger(_key)
+236
View File
@@ -0,0 +1,236 @@
"""FSM framework of the dialog engine: context, step result and the state / trigger registries.
States are plain strings, namespaced by module ("veteran.onboarding.pcl5"); core states have short names
("new", "consent", "age_gate", "idle", "crisis", "consent_revoked", ...). A flow module
(`app/dialog/flows/<module>.py`) registers its states at import time — the engine never has to be edited to
add a scenario:
from app.dialog.fsm import Ctx, Step, on_enter, state
from app.dialog.scale_session import start_scale
@on_enter("veteran.onboarding.start") # runs when the FSM *enters* the state
async def _start(ctx: Ctx) -> Step:
return await start_scale(ctx, "pcptsd5", return_state="veteran.onboarding.pcl5")
@state("veteran.onboarding.trauma") # handles events while the FSM *is in* the state
async def _trauma(ctx: Ctx, event: Event) -> Step:
...
return Step(replies=[...], next_state="idle", data={"trauma": True})
Name resolution: an exact registration wins, otherwise the longest registered dotted prefix handles the state
("scale.phq9" → the generic "scale" handler). Triggers (`@trigger("button:menu:checkin")`,
`@trigger("system:daily_checkin")`, `@trigger("text:idle")`) let flows take over what the core `idle` state does
with menu buttons, system events and free text; the core registers defaults, a flow overrides with
`replace=True`.
Prefix triggers carry data in the button (#56): `@trigger("button:med:taken:*")` handles every button whose data
starts with "med:taken:"; the rest of the data ("med:taken:42" → "42") reaches the handler as
`trigger_payload(event)`. An exact key always wins over a prefix, a longer prefix over a shorter one.
Abandoned steps (#56): a flow marks its question states with `abandonable("daily")`; if the patient stays silent
in such a state longer than `settings.abandon_after_hours`, the engine returns the FSM to `idle` before
handling the next event (answers already stored stay stored).
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.types import ScrubbedText
from app.db import repo
from app.db.clinical import PatientProfile
from app.dialog.contracts import Event, Reply
from app.dialog.texts import DialogTexts
from app.llm.gateway import LLMGateway
from app.services.contracts import MonitoringService
# States where nothing is being asked: a pending scale may start here (see engine `_start_pending`).
RESTING_STATES = frozenset({"idle", "crisis"})
# States without (valid) consent: the engine stores nothing and never calls the LLM here (152-FZ art. 9-10).
NO_CONSENT_STATES = frozenset({"new", "consent", "refused", "minor", "consent_revoked"})
@dataclass
class Ctx:
"""Everything a state handler may use. `data` is the persisted `dialog_state.data` (treat as read-only:
return changes in `Step.data`, the engine applies them)."""
token: str
state: str
data: dict[str, Any]
now: datetime # from app.core.clock, taken once per incoming event (ADR-005)
session: AsyncSession # clinical DB unit of work of this event
gateway: LLMGateway
monitoring: MonitoringService
texts: DialogTexts
text: ScrubbedText | None = None # scrubbed patient text for text events — the only text handlers see
_profile: PatientProfile | None = field(default=None, repr=False)
_profile_loaded: bool = field(default=False, repr=False)
async def profile(self) -> PatientProfile | None:
if not self._profile_loaded:
self._profile = await repo.get_profile(self.session, self.token)
self._profile_loaded = True
return self._profile
@dataclass
class Step:
"""Result of a handler.
replies — messages to send, in order;
next_state — new FSM state (None = stay); entering a different state runs its `@on_enter` hook;
data — updates merged into `dialog_state.data` (a value of None deletes the key);
notify_monitoring — the handler wrote clinical data: the engine commits and calls
`MonitoringService.after_data_change()`, then executes the returned dialog actions.
"""
replies: list[Reply] = field(default_factory=list)
next_state: str | None = None
data: dict[str, Any] = field(default_factory=dict)
notify_monitoring: bool = False
def then(self, other: Step) -> Step:
"""Sequential composition: replies concatenated, later state/data win."""
return Step(
replies=[*self.replies, *other.replies],
next_state=other.next_state or self.next_state,
data={**self.data, **other.data},
notify_monitoring=self.notify_monitoring or other.notify_monitoring,
)
Handler = Callable[[Ctx, Event], Awaitable[Step]]
EnterHook = Callable[[Ctx], Awaitable[Step]]
_handlers: dict[str, Handler] = {}
_enter_hooks: dict[str, EnterHook] = {}
_triggers: dict[str, Handler] = {}
_prefix_triggers: dict[str, Handler] = {} # "button:med:taken:" → handler (registered as "button:med:taken:*")
_abandonable: dict[str, Callable[[Ctx], bool] | None] = {}
PREFIX_WILDCARD = "*"
PAYLOAD_PARAM = "trigger_payload"
def state(name: str) -> Callable[[Handler], Handler]:
"""Register the event handler of state `name` (or of a dotted prefix, e.g. "scale")."""
def deco(fn: Handler) -> Handler:
if name in _handlers:
raise ValueError(f"state '{name}' already registered")
_handlers[name] = fn
return fn
return deco
def on_enter(name: str) -> Callable[[EnterHook], EnterHook]:
"""Register a hook that runs when the FSM enters state `name` (asks the first question, chains flows)."""
def deco(fn: EnterHook) -> EnterHook:
if name in _enter_hooks:
raise ValueError(f"enter hook of '{name}' already registered")
_enter_hooks[name] = fn
return fn
return deco
def trigger(key: str, *, replace: bool = False) -> Callable[[Handler], Handler]:
"""Register a handler for an idle-menu button / system event / idle free text.
Keys: "button:<Button.data>", "system:<SystemEventKind>", "text:idle". The core registers defaults;
flows pass `replace=True` to take one over. A key ending in ":*" is a prefix trigger (see module doc).
"""
table, name = _triggers, key
if key.endswith(":" + PREFIX_WILDCARD):
table, name = _prefix_triggers, key[: -len(PREFIX_WILDCARD)]
def deco(fn: Handler) -> Handler:
if name in table and not replace:
raise ValueError(f"trigger '{key}' already registered (use replace=True to override)")
table[name] = fn
return fn
return deco
def trigger_payload(event: Event) -> str | None:
"""Data after the matched prefix of a prefix trigger ("med:taken:42" → "42"); None for exact triggers."""
value = event.params.get(PAYLOAD_PARAM)
return str(value) if value is not None else None
def abandonable(prefix: str, when: Callable[[Ctx], bool] | None = None) -> None:
"""Mark states `prefix` / `prefix.*` as question steps the engine may abandon after a long silence.
`when(ctx)` may veto it for a particular situation (e.g. a scale that is part of an onboarding).
"""
if prefix in _abandonable:
raise ValueError(f"abandonable '{prefix}' already registered")
_abandonable[prefix] = when
def is_abandonable(ctx: Ctx) -> bool:
key = _resolve(ctx.state, _abandonable)
if key is None:
return False
when = _abandonable[key]
return when is None or when(ctx)
def _resolve(name: str, table: dict[str, Any]) -> str | None:
candidate = name
while candidate:
if candidate in table:
return candidate
if "." not in candidate:
return None
candidate = candidate.rsplit(".", 1)[0]
return None
def handler_for(name: str) -> Handler | None:
key = _resolve(name, _handlers)
return _handlers[key] if key else None
def enter_hook_for(name: str) -> EnterHook | None:
key = _resolve(name, _enter_hooks)
return _enter_hooks[key] if key else None
def get_trigger(key: str) -> Handler | None:
"""Exact trigger, else the longest matching prefix trigger (wrapped so it sees the payload)."""
exact = _triggers.get(key)
if exact is not None:
return exact
matches = [p for p in _prefix_triggers if key.startswith(p) and len(key) > len(p)]
if not matches:
return None
prefix = max(matches, key=len)
fn, payload = _prefix_triggers[prefix], key[len(prefix) :]
async def with_payload(ctx: Ctx, event: Event) -> Step:
return await fn(ctx, event.model_copy(update={"params": {**event.params, PAYLOAD_PARAM: payload}}))
return with_payload
def is_registered(name: str) -> bool:
"""Exact registration (handler or enter hook) — used to find a module's onboarding entry point."""
return name in _handlers or name in _enter_hooks
def apply_data(data: dict[str, Any], updates: dict[str, Any]) -> None:
for key, value in updates.items():
if value is None:
data.pop(key, None)
else:
data[key] = value
+288
View File
@@ -0,0 +1,288 @@
"""Reusable scale session: asks a questionnaire item by item inside the dialog (SPEC §6 "Сбор шкалы в диалоге").
Usage from a flow (#36/#37):
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9", scheduled=True)
return step # FSM goes to "scale.pcl5"; its enter hook asks item 1; on completion → return_state
Algorithm per item:
1. The item is shown with one button per answer option from `config/scales/<id>.yaml`.
2. A button press is taken as is (source="button").
3. Free text is accepted only for items without `buttons_only` (PHQ-9 item 9 and the whole C-SSRS are
buttons-only: suicidal-ideation answers are never interpreted by the LLM — SPEC §6, ADR-002). The scrubbed
text goes to `gateway.extract_item`; `None` (failure / confidence < 0.5 / out-of-range score) → a clarifying
question with the same buttons, nothing is stored (source="llm" only for a confident extraction).
4. Every answer is stored immediately (`item_responses`), so a session interrupted by a crisis keeps its data.
5. After each answer the scale is scored deterministically (`app.core.scales.score`). If a crisis flag from
`config/texts/dialog.yaml: settings.crisis_flags` fires (PHQ-9 item 9 ≥ 1, C-SSRS high) the session ends at
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS, or a C-SSRS was
completed less than `settings.cssrs_cooldown_hours` ago — the patient is not asked the same suicide screen
twice in a day; the crisis script and the alert still happen). A positive item 9 is acted upon even in a
half-finished questionnaire.
6. On completion: `scale_results` row (total, severity, item 9, scheduled flag, flags) → the engine commits
and calls `MonitoringService.after_data_change()` (rules decide alerts — ADR-007) → back to `return_state`.
The patient never sees a score or a severity label: that would read as a diagnosis.
Intros (#56): `start_scale(intro=None)` looks the intro up in a registry filled by flows
(`register_scale_intro("phq9", fn_or_text)`), so scales started by the engine (`scale_due`, rule actions) get
the flow's friendly intro without the flow hooking `scale.<id>` itself.
"""
from collections.abc import Callable
from dataclasses import replace
from datetime import timedelta
from typing import Any
from app.core.privacy import scrub
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import RESTING_STATES, Ctx, Step, abandonable, is_abandonable, on_enter, state
PREFIX = "scale"
# (ctx, scale_id, scheduled, return_state) → intro text or None (no intro in this situation).
IntroFn = Callable[[Ctx, str, bool, str], str | None]
_intros: dict[str, str | IntroFn] = {}
def register_scale_intro(scale_id: str, intro: str | IntroFn, *, replace: bool = False) -> None:
"""Register the intro shown before `scale_id` when a session is started without an explicit `intro`."""
if scale_id in _intros and not replace:
raise ValueError(f"intro of scale '{scale_id}' already registered (use replace=True to override)")
_intros[scale_id] = intro
def scale_intro(ctx: Ctx, scale_id: str, *, scheduled: bool, return_state: str) -> str | None:
intro = _intros.get(scale_id)
if intro is None or isinstance(intro, str):
return intro or None
return intro(ctx, scale_id, scheduled, return_state) or None
def scale_state(scale_id: str) -> str:
return f"{PREFIX}.{scale_id}"
def active_scale(state_name: str) -> str | None:
return state_name.split(".", 1)[1] if state_name.startswith(PREFIX + ".") else None
def add_pending(data: dict[str, Any], scale_id: str, scheduled: bool) -> list[dict[str, Any]]:
"""Queue a scale to start at the next resting state (idle/crisis); duplicates are ignored."""
pending = list(data.get("pending_scales") or [])
if scale_id not in {p["scale"] for p in pending}:
pending.append({"scale": scale_id, "scheduled": scheduled})
return pending
async def start_scale(
ctx: Ctx, scale_id: str, *, return_state: str, scheduled: bool = True, intro: str | None = None
) -> Step:
"""Begin a session of `scale_id`; returns a Step moving the FSM to `scale.<id>`.
`scheduled=False` marks an unscheduled administration (triggered by a rule or a safety signal).
`intro` — optional extra text shown before the scale instruction; None → the registered intro, if any.
"""
scale = load_scale(scale_id) # fail fast on an unknown id
if intro is None:
intro = scale_intro(ctx, scale_id, scheduled=scheduled, return_state=return_state)
sessions = dict(ctx.data.get("scales") or {})
sessions[scale_id] = {
"idx": 0,
"answers": {},
"return_state": return_state,
"scheduled": scheduled,
"intro": intro,
"intro_shown": False,
}
target = scale_state(scale_id)
if ctx.state == target: # restart in place: the enter hook would not run
sessions[scale_id]["intro_shown"] = True
return Step(replies=[_question(ctx, scale, sessions[scale_id], intro=True)], data={"scales": sessions})
return Step(next_state=target, data={"scales": sessions})
def _question(ctx: Ctx, scale: ScaleDef, session: dict[str, Any], *, intro: bool = False, note: str = "") -> Reply:
idx = session["idx"]
item = scale.items[idx]
parts: list[str] = []
if note:
parts.append(note)
if intro:
if session.get("intro"):
parts.append(session["intro"])
parts.append(ctx.texts.scale.intro.format(count=len(scale.items), instruction=scale.instruction or "").strip())
parts.append(ctx.texts.scale.question.format(n=idx + 1, total=len(scale.items), text=item.text))
buttons = [[Button(label=o.label, data=f"{PREFIX}:{scale.id}:{item.id}:{o.value}")] for o in item.options]
return Reply(text="\n\n".join(parts), buttons=buttons, kind="question")
def _session(ctx: Ctx) -> tuple[ScaleDef, dict[str, Any]] | None:
scale_id = active_scale(ctx.state)
session = (ctx.data.get("scales") or {}).get(scale_id) if scale_id else None
if scale_id is None or session is None:
return None
return load_scale(scale_id), session
def _abandon_allowed(ctx: Ctx) -> bool:
"""A silent scale session may be abandoned only if what it returns to may be (idle, a check-in step) —
never an onboarding chain, which would otherwise be skipped."""
found = _session(ctx)
if found is None:
return True
back = str(found[1].get("return_state") or "idle")
if back in RESTING_STATES:
return True
return back != ctx.state and is_abandonable(replace(ctx, state=back))
abandonable(PREFIX, _abandon_allowed)
async def recent_risk_screen(ctx: Ctx, screen: str) -> bool:
"""A complete risk screen (C-SSRS) less than `cssrs_cooldown_hours` ago (scale_results, any trigger)."""
since = ctx.now - timedelta(hours=ctx.texts.settings.cssrs_cooldown_hours)
results = await repo.list_scale_results(ctx.session, ctx.token, screen)
return any(r.complete and r.ts >= since for r in results)
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
found = _session(ctx)
if found is None:
return Step(next_state="idle")
scale, session = found
show_intro = session["idx"] == 0 and not session.get("intro_shown")
updated = {**session, "intro_shown": True}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
return Step(replies=[_question(ctx, scale, updated, intro=show_intro)], data={"scales": sessions})
def _parse_button(data: str, scale: ScaleDef, item: Item) -> int | None:
parts = data.split(":")
if len(parts) != 4 or parts[0] != PREFIX or parts[1] != scale.id or parts[2] != item.id:
return None # stale button from an earlier question
try:
value = int(parts[3])
except ValueError:
return None
return value if value in {o.value for o in item.options} else None
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
found = _session(ctx)
if found is None:
return Step(next_state="idle")
scale, session = found
item = scale.items[session["idx"]]
if event.type == "button" and event.button:
value = _parse_button(event.button, scale, item)
if value is not None:
return await _record(ctx, scale, session, item, value, source="button")
return Step(replies=[_question(ctx, scale, session)])
if event.type == "text" and ctx.text is not None:
if item.buttons_only:
# Safety items are never LLM-interpreted: ask again for a button.
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.buttons_only)])
extracted = await ctx.gateway.extract_item(item.id, item.text, item.max_value, ctx.text)
if extracted is None or extracted.score not in {o.value for o in item.options}:
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.clarify)])
return await _record(
ctx,
scale,
session,
item,
extracted.score,
source="llm",
confidence=extracted.confidence,
evidence=extracted.evidence,
)
if event.type == "system":
return Step() # reminders etc. do not interrupt a questionnaire
return Step(replies=[_question(ctx, scale, session)])
async def _record(
ctx: Ctx,
scale: ScaleDef,
session: dict[str, Any],
item: Item,
value: int,
*,
source: str,
confidence: float | None = None,
evidence: str = "",
) -> Step:
await repo.add_item_response(
ctx.session,
token=ctx.token,
scale=scale.id,
item_id=item.id,
score=value,
source=source,
ts=ctx.now,
confidence=confidence,
# Evidence is a quote of already scrubbed text; scrub again so nothing unscrubbed can be stored.
evidence=scrub(evidence) if evidence else None,
)
answers = {**session["answers"], item.id: value}
profile = await ctx.profile()
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
if crisis_flags or session["idx"] + 1 >= len(scale.items):
return await _finish(ctx, scale, session, answers, result, crisis_flags)
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
return Step(replies=[_question(ctx, scale, updated)], data={"scales": sessions})
async def _finish(
ctx: Ctx,
scale: ScaleDef,
session: dict[str, Any],
answers: dict[str, int],
result: ScaleResult,
crisis_flags: list[str],
) -> Step:
await repo.add_scale_result(
ctx.session,
token=ctx.token,
scale=scale.id,
total=result.total,
severity=result.severity_code,
complete=result.complete,
ts=ctx.now,
# PHQ-9 item 9 is stored separately: any value >= 1 escalates regardless of the total.
item9=answers.get("phq9_9") if scale.id == "phq9" else None,
scheduled=bool(session.get("scheduled", True)),
flags={f: True for f in result.flags},
)
sessions = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale.id}
done = Step(
replies=[Reply(text=ctx.texts.scale.done)],
next_state=session["return_state"],
data={"scales": sessions},
notify_monitoring=True,
)
if not crisis_flags:
return done
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
risk_screen = ctx.texts.settings.risk_screen_scale
if crisis_open(ctx.data):
crisis = Step(next_state="crisis")
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)
# Same-day repeat guard: a C-SSRS completed within the cooldown already answered the screening question.
if scale.id != risk_screen and not await recent_risk_screen(ctx, risk_screen):
step.data["pending_scales"] = add_pending(ctx.data, risk_screen, scheduled=False)
return step
+123
View File
@@ -0,0 +1,123 @@
"""Patient-facing dialog texts from `config/texts/dialog.yaml` (ADR-008: texts live in config, not code)."""
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class _Strict(BaseModel):
# A typo in a YAML key must fail at load time, not silently show an empty message to a patient.
model_config = ConfigDict(extra="forbid", frozen=True)
class DialogSettings(_Strict):
cssrs_cooldown_hours: float = 24
max_safety_events: int = 50
crisis_flags: list[str] = Field(default_factory=list)
risk_screen_scale: str = "cssrs_screen"
abandon_after_hours: float = 6
class Commands(_Strict):
revoke: list[str]
class ConsentTexts(_Strict):
version: str
text: str
accept: str
decline: str
refused: str
reconsider: str
code_invalid: str
class AgeGateTexts(_Strict):
question: str
adult: str
minor: str
unclear: str
minor_stop: str
class IdleButtons(_Strict):
checkin: str
med_question: str
kb_question: str
contact_doctor: str
revoke: str
class IdleTexts(_Strict):
welcome: str
menu_prompt: str
buttons: IdleButtons
stub_checkin: str
stub_med_question: str
contact_doctor_ack: str
visit_stub: str
class RevokedTexts(_Strict):
confirm: str
class CrisisTexts(_Strict):
repeat: str
hold: str
closed: str
class ScaleTexts(_Strict):
intro: str
question: str
clarify: str
buttons_only: str
done: str
risk_screen_intro: str
class SafetyPlanTexts(_Strict):
title: str
template: list[str]
means_restriction: str
class ReachOutTexts(_Strict):
silence: str
missed_doses: str
default: str
class VisitTexts(_Strict):
button: str
class DialogTexts(_Strict):
status: str
settings: DialogSettings
commands: Commands
consent: ConsentTexts
age_gate: AgeGateTexts
idle: IdleTexts
revoked: RevokedTexts
crisis: CrisisTexts
scale: ScaleTexts
safety_plan: SafetyPlanTexts
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
def dialog_texts_path() -> Path:
return get_settings().config_dir / "texts" / "dialog.yaml"
@lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path()
return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+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
+35
View File
@@ -0,0 +1,35 @@
"""ЕМИАС sign-in mock for the patient web app (SPEC §9 `esia_mock`: registration by the doctor's code).
Real ЕМИАС / ЕСИА OAuth needs a registered information system, so the prototype accepts the one-time connect
code the doctor hands out after the visit (323-FZ art. 36.2): code → patient token + attending doctor. The
class implements `IdentityProvider`, so the real OAuth client replaces it without touching the web channel.
It is deliberately NOT installed into `integrations.identity`: the Telegram channel verifies a code twice
(on `/start` and in the core flow), which a one-time redeeming provider would break. Lane B wires the
production provider for all channels (#14).
This module may use the identity DB: it is the registration boundary (guardrail #5).
"""
from app.core import clock
from app.db import identity_repo
from app.db.identity import ConnectCode
from app.db.session import get_identity_session
from app.integrations.base import VerifiedPerson
CODE_MAX_LEN = 32
class EmiasMock:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
code = auth_code.strip().upper()
if not code or len(code) > CODE_MAX_LEN:
return None
async with get_identity_session() as s:
row = await s.get(ConnectCode, code)
if row is None:
return None
token = await identity_repo.redeem_connect_code(s, code=code, now=clock.now())
if token is None:
return None
return VerifiedPerson(token=token, doctor_id=row.doctor_id)
+94
View File
@@ -0,0 +1,94 @@
"""ЕМИАС medical record mock: diagnoses, comorbidities, allergies and other specialists' prescriptions.
Prod: the regional МИС / ЕГИСЗ adapter replaces `EmiasRecordSource` (same `get(token)` contract). Demo: synthetic
records from `data/emias/records.yaml` keyed by synthetic SNILS, converted to tokens with `pseudo_id()` at load
time — so the record handed to the chat agent carries no SNILS, name or phone (guardrail #1). The current
psychiatric prescription comes from the МИС mock (`integrations.prescriptions`).
"""
import logging
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.core.privacy import pseudo_id
from app.integrations.base import integrations
log = logging.getLogger(__name__)
class Diagnosis(BaseModel):
code: str
name: str
class Condition(BaseModel):
name: str
tags: list[str] = Field(default_factory=list)
class OtherMedication(BaseModel):
drug: str
how: str = ""
prescribed_by: str = ""
classes: list[str] = Field(default_factory=list)
class CurrentPrescription(BaseModel):
drug: str
dose: str
instructions: str = ""
class PatientRecord(BaseModel):
"""Clinical-only view of the record (no identifiers): what the chat agent may see."""
diagnoses: list[Diagnosis] = Field(default_factory=list)
conditions: list[Condition] = Field(default_factory=list)
allergies: list[Condition] = Field(default_factory=list)
other_medications: list[OtherMedication] = Field(default_factory=list)
pregnancy: dict[str, str] | None = None
current_prescriptions: list[CurrentPrescription] = Field(default_factory=list)
def default_records_path() -> Path:
return get_settings().data_dir / "emias" / "records.yaml"
@lru_cache
def _load(path: Path) -> dict[str, PatientRecord]:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
out: dict[str, PatientRecord] = {}
for snils, rec in (raw.get("records") or {}).items():
out[pseudo_id(snils)] = PatientRecord.model_validate(rec)
return out
class EmiasRecordSource:
def __init__(self, path: Path | None = None) -> None:
self._path = path
async def get(self, token: str) -> PatientRecord | None:
try:
base = _load(self._path or default_records_path()).get(token)
except (OSError, yaml.YAMLError, ValueError) as exc:
log.error("emias records unavailable: %s", type(exc).__name__)
base = None
record = base.model_copy(deep=True) if base is not None else PatientRecord()
try:
rx = await integrations.prescriptions.get_prescriptions(token)
except Exception as exc: # noqa: BLE001 — the МИС being down must not break the chat
log.error("prescriptions unavailable: %s", type(exc).__name__)
rx = []
record.current_prescriptions = [
CurrentPrescription(drug=p.drug, dose=p.dose, instructions=p.instructions) for p in rx
]
if base is None and not rx:
return None
return record
emias_records = EmiasRecordSource()
+80
View File
@@ -0,0 +1,80 @@
"""ГРЛС stand-in: short excerpts of the official drug instruction, per section (SPEC §9, SPEC §7 п.5).
Each `data/meds/grls/<name>.yaml` holds one drug:
inn: сертралин
aliases: [sertraline]
sections:
side_effects: {text: "...", source: "ГРЛС, инструкция по медицинскому применению, раздел «...»"}
Section codes are the ones `app/llm/med_qa.py` understands (side_effects, onset, alcohol, discontinuation,
administration, interactions, overdose). The bot answers drug questions ONLY from these texts, so they are
the single place to verify against the current ГРЛС instruction (see the header of each file).
"""
import logging
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, ValidationError
from app.config import get_settings
from app.integrations.base import DrugSection
log = logging.getLogger(__name__)
class _SectionFile(BaseModel):
text: str
source: str
class _DrugFile(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
sections: dict[str, _SectionFile]
def default_grls_dir() -> Path:
return get_settings().data_dir / "meds" / "grls"
def normalize_inn(name: str) -> str:
"""Lookup key: «Сертралин 50 мг», «сертралин», «Sertraline» -> first word, lower case, ё -> е."""
words = name.strip().lower().replace("ё", "е").split()
return words[0] if words else ""
class GrlsExcerpts:
"""`DrugInfo` over local YAML excerpts. Files are read lazily on the first lookup and cached."""
def __init__(self, directory: Path | None = None) -> None:
self._dir = directory
self._index: dict[str, list[DrugSection]] | None = None
def _load(self) -> dict[str, list[DrugSection]]:
if self._index is not None:
return self._index
directory = self._dir or default_grls_dir()
index: dict[str, list[DrugSection]] = {}
for path in sorted(directory.glob("*.yaml")):
try:
drug = _DrugFile.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
except (OSError, yaml.YAMLError, ValidationError) as exc:
log.warning("grls file skipped file=%s error=%s", path.name, type(exc).__name__)
continue
sections = [
DrugSection(inn=drug.inn, section=code, text=" ".join(s.text.split()), source=s.source)
for code, s in drug.sections.items()
]
for key in (drug.inn, *drug.aliases):
index[normalize_inn(key)] = sections
self._index = index
return index
def known(self) -> list[str]:
"""INNs with excerpts (for the dashboard / tests)."""
return sorted({s[0].inn for s in self._load().values() if s})
async def sections(self, inn: str) -> list[DrugSection]:
return [s.model_copy() for s in self._load().get(normalize_inn(inn), [])]
+97
View File
@@ -0,0 +1,97 @@
"""МИС / ЕГИСЗ stand-in: the patient's prescriptions (SPEC §9).
Source order: the clinical DB table `prescriptions` (written when the doctor connects a patient or by the
demo seed), then the synthetic catalogue `data/meds/mis_prescriptions.json` keyed by token. In prod this is
a regional МИС query; the Protocol stays the same.
"""
import json
import logging
from datetime import date
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.integrations.base import Prescription
log = logging.getLogger(__name__)
class _CatalogPatient(BaseModel):
persona: str
tokens: list[str] = Field(default_factory=list)
prescriptions: list[Prescription]
class _Catalog(BaseModel):
patients: list[_CatalogPatient]
def _from_row(drug: str, dose: str, schedule: dict[str, Any], start: date | None, end: date | None) -> Prescription:
# The table has no instructions column; the doctor's note («утром после еды») may ride in the schedule JSON.
times = {k: [str(x) for x in v] for k, v in schedule.items() if isinstance(v, list)}
return Prescription(
drug=drug,
dose=dose,
schedule=times or {"times": ["08:00"]},
instructions=str(schedule.get("instructions") or ""),
start_date=start,
end_date=end,
)
def default_catalog_path() -> Path:
return get_settings().data_dir / "meds" / "mis_prescriptions.json"
class MisMock:
"""`PrescriptionSource`: clinical DB first, synthetic JSON catalogue as the fallback."""
def __init__(self, catalog_path: Path | None = None, *, use_db: bool = True) -> None:
self._path = catalog_path
self._use_db = use_db
self._by_token: dict[str, list[Prescription]] | None = None
def _catalog(self) -> dict[str, list[Prescription]]:
# Lazy: the package is imported before startup, a missing/broken file must not break the import.
if self._by_token is None:
path = self._path or default_catalog_path()
index: dict[str, list[Prescription]] = {}
try:
catalog = _Catalog.model_validate(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, ValidationError) as exc:
log.warning("mis_mock catalogue not loaded error=%s", type(exc).__name__)
catalog = _Catalog(patients=[])
for patient in catalog.patients:
for token in patient.tokens:
index[token] = patient.prescriptions
self._by_token = index
return self._by_token
async def _from_db(self, token: str) -> list[Prescription]:
from app.db.clinical import Prescription as PrescriptionRow
from app.db.session import get_clinical_session
async with get_clinical_session() as s:
rows = (
await s.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == token).order_by(PrescriptionRow.id)
)
).all()
return [_from_row(r.drug, r.dose, r.schedule or {}, r.start_date, r.end_date) for r in rows]
async def get_prescriptions(self, token: str) -> list[Prescription]:
if self._use_db:
try:
found = await self._from_db(token)
except (OSError, SQLAlchemyError) as exc:
# DB down or not migrated: the demo still shows the catalogue prescription.
log.warning("mis_mock db lookup failed error=%s", type(exc).__name__)
found = []
if found:
return found
return [p.model_copy(deep=True) for p in self._catalog().get(token, [])]
+141
View File
@@ -0,0 +1,141 @@
"""Doctor notifications (SPEC §9): in-memory feed for the dashboard + optional Telegram doctor chat.
The alert itself is already committed to the clinical DB before `send()` is called (the dashboard reads
alerts from there via the API), so every channel here is best effort: failures are logged, never raised.
Messages carry only the level, the rule reason/code and a short pseudonymous patient id — no PII
(guardrail #8); the reason texts come from `config/rules.yaml`, not from patient messages.
"""
import asyncio
import itertools
import logging
from collections import deque
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from app.config import get_settings
from app.core import clock
from app.integrations.base import AlertLevelName
log = logging.getLogger(__name__)
FEED_SIZE = 500
SHORT_ID_LEN = 6
MAX_REASON_CHARS = 200
TELEGRAM_TIMEOUT_S = 5.0
# Doctor-facing labels (not patient texts); acute alerts are visually distinct in the chat.
LEVEL_LABELS: dict[str, str] = {
"acute": "КРИТИЧНО",
"high": "Высокий",
"moderate": "Умеренный",
"info": "Инфо",
}
ChatSender = Callable[[int, str], Awaitable[Any]]
def short_id(token: str | None) -> str:
return token[:SHORT_ID_LEN] if token else "—"
def format_message(level: str, text: str, token: str | None) -> str:
reason = " ".join(text.split())
if len(reason) > MAX_REASON_CHARS:
reason = reason[: MAX_REASON_CHARS - 1] + "…"
return f"[{LEVEL_LABELS.get(level, level)}] {reason}\nПациент #{short_id(token)}"
class Notification(BaseModel):
id: int
ts: datetime
doctor_id: str
level: str
text: str
token: str | None = None
class NotificationFeed:
"""Bounded in-memory feed; ids increase monotonically so a poller asks only for `recent(since_id)`."""
def __init__(self, size: int = FEED_SIZE) -> None:
self._items: deque[Notification] = deque(maxlen=size)
self._ids = itertools.count(1)
def add(self, doctor_id: str, level: str, text: str, token: str | None, ts: datetime) -> Notification:
item = Notification(id=next(self._ids), ts=ts, doctor_id=doctor_id, level=level, text=text, token=token)
self._items.append(item)
return item
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return [n for n in self._items if n.id > since_id and (doctor_id is None or n.doctor_id == doctor_id)]
class TelegramDoctorChat:
"""Sends to the doctor chat `DOCTOR_NOTIFY_CHAT_ID` through the bot `TG_BOT_TOKEN`.
The aiogram `Bot` is created on the first message only; with either setting empty the channel is off.
"""
def __init__(self, chat_id: int | None = None, bot_token: str | None = None, sender: ChatSender | None = None):
settings = get_settings()
self.chat_id = chat_id if chat_id is not None else settings.doctor_notify_chat_id
self._bot_token = bot_token if bot_token is not None else settings.tg_bot_token
self._sender = sender
self._bot: Any = None
@property
def enabled(self) -> bool:
return self.chat_id is not None and (self._sender is not None or bool(self._bot_token))
def _get_sender(self) -> ChatSender:
if self._sender is None:
from aiogram import Bot
self._bot = Bot(self._bot_token)
self._sender = self._bot.send_message
return self._sender
async def send(self, level: str, text: str, token: str | None) -> None:
if not self.enabled or self.chat_id is None:
return
try:
await asyncio.wait_for(
self._get_sender()(self.chat_id, format_message(level, text, token)), timeout=TELEGRAM_TIMEOUT_S
)
except Exception as exc: # noqa: BLE001 — any channel error; the alert is already in the DB
err = type(exc).__name__
log.warning("doctor chat notify failed level=%s tok=%s error=%s", level, short_id(token), err)
async def aclose(self) -> None:
if self._bot is not None:
await self._bot.session.close()
self._bot = None
self._sender = None
class CompositeNotifier:
"""`Notifier`: dashboard feed + Telegram doctor chat (when configured)."""
def __init__(self, feed: NotificationFeed | None = None, chat: TelegramDoctorChat | None = None) -> None:
self.feed = feed or NotificationFeed()
self._chat = chat
@property
def chat(self) -> TelegramDoctorChat:
# Lazy: settings are read on the first notification, not when the package is imported.
if self._chat is None:
self._chat = TelegramDoctorChat()
return self._chat
def recent(self, since_id: int = 0, doctor_id: str | None = None) -> list[Notification]:
return self.feed.recent(since_id, doctor_id)
async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None:
try:
self.feed.add(doctor_id, level, text, token, clock.now())
except Exception as exc: # noqa: BLE001 — never break the caller
log.warning("notify feed failed error=%s", type(exc).__name__)
await self.chat.send(level, text, token)
+151
View File
@@ -0,0 +1,151 @@
"""Chat agent: tool-using LLM over the knowledge base (RAG), the ЕМИАС record and the drug-safety reference.
from app.llm.agent import answer_chat
reply = await answer_chat(token, scrubbed_question) # None → caller falls back to extractive RAG / med_qa
Guarantees (on top of what the dialog engine already did — scrub, consent, safety check; ACUTE never gets here):
* only scrubbed text goes to the provider (`assert_scrubbed`), tool results carry no identifiers;
* tools are bound to the current token server-side; read-only;
* the final text passes the gateway's forbidden-output filter (diagnoses, dose/drug advice); a violation or any
provider error → None, so the patient gets the safe extractive answer instead;
* logs carry tool names and counts only.
"""
import asyncio
import json
import logging
from typing import Any
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.core.types import ScrubbedText
from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import (
MockAgentProvider,
OpenRouterProvider,
ProviderError,
ToolChatProvider,
)
from app.llm.agent.tools import TOOL_SPECS, ToolContext, run_tool
from app.llm.gateway import is_forbidden
from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
class AgentAnswer(BaseModel):
text: str
sources: list[str]
tools: list[str]
provider: str
def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings()
mode = s.chat_agent
if mode == "auto":
mode = "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter":
if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
return None
return OpenRouterProvider(s)
if mode == "mock":
return MockAgentProvider()
if mode == "gigachat":
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call).
log.warning("chat agent: gigachat provider not implemented yet — disabled")
return None
return None
async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record()
if rec is None or not rec.current_prescriptions:
return "\n\nТекущее назначение психиатра: нет данных."
drugs = ", ".join(p.drug for p in rec.current_prescriptions)
return f"\n\nТекущее назначение психиатра: {drugs}. «Мои таблетки» = это назначение."
def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending."""
return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
async def answer_chat(
token: str, question: ScrubbedText, *, provider: ToolChatProvider | None = None, settings: Settings | None = None
) -> AgentAnswer | None:
s = settings or get_settings()
provider = provider or build_provider(s)
if provider is None:
return None
try:
assert_scrubbed(question)
except ValueError:
log.warning("chat agent: unscrubbed input rejected")
return None
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
{"role": "user", "content": str(question)},
]
nudged = False
try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS)
if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results.
nudged = True
messages.append({"role": "user", "content": NUDGE})
continue
return _finish(turn.content or "", ctx, provider.name)
messages.append(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
],
}
)
for c in turn.tool_calls:
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None
except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout")
return None
def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
text = text.strip()
if not text:
return None
if is_forbidden(text):
log.warning("chat agent: forbidden output filtered provider=%s", provider)
return None
if len(text) > MAX_ANSWER_CHARS:
text = text[:MAX_ANSWER_CHARS].rsplit(" ", 1)[0] + "…"
log.info("chat agent ok provider=%s tools=%s sources=%d", provider, ",".join(ctx.used), len(ctx.sources))
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
+59
View File
@@ -0,0 +1,59 @@
"""Try the chat agent from the shell (synthetic personas only):
uv run python -m app.llm.agent "Можно мне ибупрофен вместе с моими таблетками?" --persona andrey
CHAT_AGENT=mock uv run python -m app.llm.agent "Есть ли лактоза в сертралине?"
Prescriptions come from the МИС mock (DB if reachable, else data/meds/mis_prescriptions.json); the question is
scrubbed before it reaches any provider.
"""
import argparse
import asyncio
import logging
from app.config import get_settings
from app.core.privacy import pseudo_id, scrub
from app.integrations.base import Prescription, integrations
from app.llm.agent import answer_chat, build_provider, render
PERSONAS = { # synthetic SNILS from scripts/seed_demo.py → current psychiatric prescription
"andrey": ("900-000-013 34", "сертралин"),
"dmitry": ("900-000-041 01", "сертралин"),
"sergey": ("900-000-037 02", "пароксетин"),
"nikolay": ("900-000-052 03", "сертралин"),
"anna": ("900-000-023 04", "флуоксетин"),
"pavel": ("900-000-045 07", "эсциталопрам"),
}
class _FixedRx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="по назначению врача")]
async def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("question")
p.add_argument("--persona", default="andrey", choices=sorted(PERSONAS))
p.add_argument("-v", "--verbose", action="store_true")
a = p.parse_args()
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING)
snils, drug = PERSONAS[a.persona]
integrations.prescriptions = _FixedRx(drug) # CLI runs without the DB
provider = build_provider()
if provider is None:
raise SystemExit(
f"agent disabled (CHAT_AGENT={get_settings().chat_agent}); set OPENROUTER_API_KEY or CHAT_AGENT=mock"
)
ans = await answer_chat(pseudo_id(snils), scrub(a.question), provider=provider)
if ans is None:
raise SystemExit("no answer (provider error, filtered output or nothing found) — chat falls back to RAG")
print(render(ans))
print(f"\n[provider={ans.provider} tools={', '.join(ans.tools)}]")
if __name__ == "__main__":
asyncio.run(main())
+147
View File
@@ -0,0 +1,147 @@
"""Deterministic drug-safety lookup: composition, contraindications, interactions, patient-specific findings.
The LLM never decides whether a drug is safe for the patient (ADR-007 spirit): it calls `check_patient` and
retells the findings. Matching is by tags shared with the ЕМИАС record (`data/emias/records.yaml`):
condition tags → `cautions[].when_condition`, allergy tags → `composition.excipient_tags`, other medication
classes → `interactions[].with_class`. Source data: `data/meds/drug_safety.yaml` (prototype retelling of ГРЛС).
"""
from functools import lru_cache
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, Field
from app.config import get_settings
from app.integrations.emias_record import PatientRecord
Severity = Literal["high", "medium", "low"]
class Caution(BaseModel):
when_condition: str
severity: Severity
text: str
class Interaction(BaseModel):
with_class: str
severity: Severity
text: str
class Composition(BaseModel):
form: str
active: str
excipients: list[str] = Field(default_factory=list)
excipient_tags: list[str] = Field(default_factory=list)
note: str = ""
class DrugEntry(BaseModel):
inn: str
aliases: list[str] = Field(default_factory=list)
drug_class: str = ""
classes: list[str] = Field(default_factory=list) # classes this drug belongs to (e.g. nsaid, ssri)
contraindications: list[str] = Field(default_factory=list)
cautions: list[Caution] = Field(default_factory=list)
interactions: list[Interaction] = Field(default_factory=list)
composition: Composition | None = None
pregnancy: str = ""
source: str = ""
class Finding(BaseModel):
severity: Severity
kind: Literal["condition", "allergy", "interaction", "pregnancy", "contraindication"]
text: str
class SafetyDb(BaseModel):
drugs: dict[str, DrugEntry]
allergy_labels: dict[str, str] = Field(default_factory=dict)
def _path() -> Path:
return get_settings().data_dir / "meds" / "drug_safety.yaml"
@lru_cache
def load_db(path: Path | None = None) -> SafetyDb:
raw = yaml.safe_load((path or _path()).read_text(encoding="utf-8"))
classes_of: dict[str, list[str]] = {}
for cls, members in (raw.get("drug_classes") or {}).items():
for m in members:
classes_of.setdefault(m, []).append(cls)
drugs: dict[str, DrugEntry] = {}
for inn, d in (raw.get("drugs") or {}).items():
entry = DrugEntry(
inn=inn,
aliases=d.get("aliases", []),
drug_class=d.get("class", ""),
classes=sorted(set(d.get("classes", [])) | set(classes_of.get(inn, []))),
contraindications=d.get("contraindications", []),
cautions=d.get("cautions", []),
interactions=d.get("interactions", []),
composition=d.get("composition"),
pregnancy=d.get("pregnancy", ""),
source=d.get("source", ""),
)
drugs[inn] = entry
return SafetyDb(drugs=drugs, allergy_labels=raw.get("allergy_labels") or {})
def find_drug(name: str, db: SafetyDb | None = None) -> DrugEntry | None:
"""By INN or alias, case-insensitive; "сертралин 50 мг" → сертралин."""
db = db or load_db()
words = [w.strip(".,!?;:«»()") for w in name.lower().replace("ё", "е").split()]
for entry in db.drugs.values():
names = {entry.inn, *(a.lower() for a in entry.aliases)}
# Russian case endings: "сертралине", "ибупрофена" — match on the stem (name minus the last letter).
if any(w in names or any(len(n) > 4 and w.startswith(n[:-1]) for n in names) for w in words):
return entry
return None
def check_patient(drug: DrugEntry, record: PatientRecord, db: SafetyDb | None = None) -> list[Finding]:
"""Patient-specific findings for `drug`, most severe first. Empty list = nothing found in the reference —
which is NOT a statement that the drug is safe (the answer must say so)."""
db = db or load_db()
out: list[Finding] = []
cond_tags = {t for c in record.conditions for t in c.tags}
for c in drug.cautions:
if c.when_condition in cond_tags:
out.append(Finding(severity=c.severity, kind="condition", text=c.text))
# Excipients vs allergies / intolerances.
allergy_tags = {t for a in record.allergies for t in a.tags}
if drug.composition is not None:
for tag in sorted(allergy_tags & set(drug.composition.excipient_tags)):
label = db.allergy_labels.get(tag, tag)
out.append(
Finding(
severity="high", kind="allergy", text=f"В составе есть {label}, а в карте отмечена непереносимость."
)
)
# Interactions with the patient's other medicines and with the current psychiatric prescription.
other_classes: dict[str, str] = {}
for m in record.other_medications:
for cls in m.classes:
other_classes[cls] = m.drug
for rx in record.current_prescriptions:
entry = find_drug(rx.drug, db)
if entry is not None and entry.inn != drug.inn:
for cls in entry.classes:
other_classes.setdefault(cls, rx.drug)
for i in drug.interactions:
if i.with_class in other_classes:
out.append(
Finding(
severity=i.severity, kind="interaction", text=f"{i.text} (в карте: {other_classes[i.with_class]})."
)
)
if record.pregnancy and drug.pregnancy:
out.append(Finding(severity="medium", kind="pregnancy", text=drug.pregnancy))
order = {"high": 0, "medium": 1, "low": 2}
out.sort(key=lambda f: order[f.severity])
return out
+31
View File
@@ -0,0 +1,31 @@
"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools."""
SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у
психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах.
Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано».
Запрещено:
- ставить или называть диагнозы («у вас депрессия»), даже если они есть в карте;
- назначать, отменять препараты, менять дозы, советовать «примите/не принимайте» — решение всегда за врачом;
- называть дозировки в миллиграммах;
- проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия 8-800-2000-122, и что врач будет уведомлён."""
+179
View File
@@ -0,0 +1,179 @@
"""Tool-calling chat providers for the agent.
* `OpenRouterProvider` — OpenAI-compatible `/chat/completions` with `tools`. TEMPORARY for the prototype on
synthetic data: OpenRouter is a foreign service, forbidden for real PII (152-FZ art. 18 p. 5). Replace with
GigaChat (function calling) before any real patient — same `ToolChatProvider` contract.
* `MockAgentProvider` — offline, deterministic: plans tool calls from keywords and composes an answer from the
tool results. Keeps the demo working without network (ADR-004).
"""
import json
import logging
import re
from typing import Any, Protocol
import httpx
from pydantic import BaseModel, Field
from app.config import Settings
log = logging.getLogger(__name__)
class ToolCall(BaseModel):
id: str
name: str
arguments: dict[str, Any] = Field(default_factory=dict)
class AssistantTurn(BaseModel):
content: str | None = None
tool_calls: list[ToolCall] = Field(default_factory=list)
class ToolChatProvider(Protocol):
name: str
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: ...
class ProviderError(RuntimeError):
pass
class OpenRouterProvider:
name = "openrouter"
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
self._key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
self._timeout = settings.chat_agent_timeout_s
self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required" # never answer from the model's own memory: ground first
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
try:
if self._client is not None:
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
else:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200:
raise ProviderError(f"http {resp.status_code}")
try:
msg = resp.json()["choices"][0]["message"]
except (ValueError, KeyError, IndexError) as exc:
raise ProviderError("bad response") from exc
calls = []
for c in msg.get("tool_calls") or []:
fn = c.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=c.get("id") or fn.get("name", "call"), name=fn.get("name", ""), arguments=args))
usage = resp.json().get("usage") or {}
log.info("openrouter ok calls=%d tokens=%s", len(calls), usage.get("total_tokens"))
return AssistantTurn(content=msg.get("content"), tool_calls=calls)
# ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile(
r"\b(сертралин\w*|пароксетин\w*|эсциталопрам\w*|флуоксетин\w*|ибупрофен\w*|нурофен\w*|золофт\w*)", re.I
)
_MED_HINT = re.compile(r"(таблет|лекарств|препарат|состав|можно ли|совмест|вместе|противопоказ|аллерг|лактоз)", re.I)
class MockAgentProvider:
"""Plans: record → (drug check for each mentioned drug, or the current prescription for medicine questions)
→ guidelines search; then writes a short answer strictly from the tool results."""
name = "mock"
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
question = next(m["content"] for m in reversed(messages) if m["role"] == "user")
done = [m for m in messages if m["role"] == "tool"]
if not done:
drugs = [m.group(1).lower() for m in _DRUG_WORDS.finditer(question)]
calls = [ToolCall(id="c0", name="get_patient_record")]
calls += [
ToolCall(id=f"d{i}", name="check_drug_for_patient", arguments={"drug": d}) for i, d in enumerate(drugs)
]
if drugs and re.search(r"состав|лактоз|желатин|аллерг|непереносим", question, re.I):
calls += [
ToolCall(id=f"i{i}", name="get_drug_info", arguments={"drug": d}) for i, d in enumerate(drugs)
]
calls.append(ToolCall(id="s0", name="search_guidelines", arguments={"query": question}))
return AssistantTurn(tool_calls=calls)
mine = re.search(r"мо(и|ими|их|ей|я|ё) (таблет|лекарств|препарат)", question, re.I)
checked = [m for m in done if m.get("name") == "check_drug_for_patient"]
if (not checked or mine) and len(checked) < 3 and _MED_HINT.search(question):
record = _tool_json(done, "get_patient_record")
rx = (record or {}).get("current_prescriptions") or []
checked_args = {str(json.loads(m["content"]).get("inn", "")) for m in checked}
rx = [r for r in rx if not any(r["drug"].lower().startswith(c[:-1]) for c in checked_args if c)]
if rx:
return AssistantTurn(
tool_calls=[ToolCall(id="d9", name="check_drug_for_patient", arguments={"drug": rx[0]["drug"]})]
)
return AssistantTurn(content=_compose(done))
def _tool_json(done: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
for m in done:
if m.get("name") == name:
try:
return json.loads(m["content"])
except json.JSONDecodeError:
return None
return None
def _compose(done: list[dict[str, Any]]) -> str:
parts: list[str] = []
seen: set[str] = set()
for m in done:
if m.get("name") != "check_drug_for_patient":
continue
data = json.loads(m["content"])
if not data.get("found") or data["inn"] in seen:
continue
seen.add(data["inn"])
findings = data.get("findings") or []
if findings:
parts.append(f"По вашей карте и инструкции к препарату «{data['inn']}» есть важное:")
parts += [f"• {f['text']}" for f in findings[:3]]
else:
parts.append(f"В справочнике не нашлось особых ограничений по «{data['inn']}» с учётом вашей карты.")
for m in done:
if m.get("name") != "get_drug_info":
continue
data = json.loads(m["content"])
comp = data.get("composition") if data.get("found") else None
if comp:
record = _tool_json(done, "get_patient_record") or {}
allergies = record.get("allergies") or []
if allergies:
names = ", ".join(a["name"] for a in allergies)
hit = {t for a in allergies for t in a.get("tags", [])} & set(comp.get("excipient_tags", []))
verdict = "это вещество есть" if hit else "этого вещества нет, но состав зависит от производителя"
parts.append(f"В вашей карте: {names}. В примере состава «{data['inn']}» {verdict}.")
excipients = ", ".join(comp["excipients"])
parts.append(f"Состав ({comp['form']}): {comp['active']}; вспомогательные: {excipients}. {comp['note']}")
search = _tool_json(done, "search_guidelines") or {}
results = search.get("results") or []
if results and not parts: # medicine findings already answer the question; a KR quote would dilute it
text = results[0]["text"].strip()
parts.append(f"В клинических рекомендациях: «{text[:400]}{'…' if len(text) > 400 else ''}»")
if not parts:
return ""
parts.append("Любые изменения в лечении обсуждайте с вашим врачом.")
return "\n".join(parts)
+200
View File
@@ -0,0 +1,200 @@
"""Read-only tools of the chat agent. Every tool is bound to the current patient's token on the server side:
the model never passes a token, so it cannot read another patient's data (medical secrecy, 323-FZ art. 13).
Tool results contain clinical data only (no name, SNILS, phone) — they are sent to the LLM provider.
"""
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any
from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve
from app.llm.rag.index import sentences
log = logging.getLogger(__name__)
RAG_TOP_K = 4
PASSAGE_CHARS = 700
# Patients never get dosages (SPEC §7): sentences with doses are dropped before the text reaches the LLM.
_DOSE = re.compile(r"\d\s*(?:-|–)?\s*\d*\s*(?:мг|mg|мкг|г/сут|таблетк)|\bдоз(?:а|у|ы|е|ой|ировк)", re.I)
TOOL_SPECS: list[dict[str, Any]] = [
{
"type": "function",
"function": {
"name": "search_guidelines",
"description": (
"Поиск по базе знаний: клинические рекомендации Минздрава РФ (депрессия 2024, ПТСР 2023). "
"Возвращает фрагменты с источником и страницей. Используй для любых общих вопросов о состоянии, "
"лечении, сроках действия терапии, побочных эффектах, отмене, образе жизни."
),
"parameters": {
"type": "object",
"properties": {"query": {"type": "string", "description": "Поисковый запрос на русском"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_patient_record",
"description": (
"Медицинская карта текущего пациента из ЕМИАС: диагнозы (МКБ-10), сопутствующие заболевания, "
"аллергии и непереносимости, назначения других врачей и текущее назначение психиатра. "
"Вызывай, когда ответ зависит от особенностей пациента или его лекарств."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "get_drug_info",
"description": (
"Справка о препарате по инструкции ГРЛС: состав (действующее и вспомогательные вещества), "
"противопоказания, взаимодействия, беременность. Аргумент — название препарата (МНН или торговое)."
),
"parameters": {
"type": "object",
"properties": {"drug": {"type": "string"}},
"required": ["drug"],
},
},
},
{
"type": "function",
"function": {
"name": "check_drug_for_patient",
"description": (
"Детерминированная проверка препарата для текущего пациента: сопутствующие заболевания, "
"непереносимости по составу, взаимодействия с другими его лекарствами. Используй перед любым "
"ответом о том, подходит ли пациенту препарат или сочетание препаратов."
),
"parameters": {
"type": "object",
"properties": {
"drug": {"type": "string", "description": "Название препарата; пусто — текущее назначение пациента"}
},
},
},
},
]
@dataclass
class ToolContext:
token: str
sources: list[str] = field(default_factory=list) # citations collected for the answer footer
used: list[str] = field(default_factory=list)
_record: PatientRecord | None = None
_record_loaded: bool = False
async def record(self) -> PatientRecord | None:
if not self._record_loaded:
self._record = await emias_records.get(self.token)
self._record_loaded = True
return self._record
def cite(self, label: str) -> None:
if label and label not in self.sources:
self.sources.append(label)
async def run_tool(ctx: ToolContext, name: str, args: dict[str, Any]) -> str:
"""Execute a tool; always returns a JSON string (errors included) so the model can recover."""
ctx.used.append(name)
log.info("agent tool=%s", name) # no arguments: they may echo patient text
try:
if name == "search_guidelines":
return _json(_search(ctx, str(args.get("query", ""))))
if name == "get_patient_record":
rec = await ctx.record()
if rec is None:
return _json({"found": False})
ctx.cite("ЕМИАС: медицинская карта")
return _json({"found": True, **rec.model_dump()})
if name == "get_drug_info":
return _json(_drug_info(ctx, str(args.get("drug", ""))))
if name == "check_drug_for_patient":
return _json(await _check(ctx, str(args.get("drug", ""))))
return _json({"error": f"unknown tool {name}"})
except Exception as exc: # noqa: BLE001 — a tool failure must not crash the chat
log.error("agent tool=%s failed: %s", name, type(exc).__name__)
return _json({"error": "tool failed"})
def _search(ctx: ToolContext, query: str) -> dict[str, Any]:
hits = retrieve(query, audience="patient", top_k=RAG_TOP_K) if query.strip() else []
out = []
for h in hits:
label = h.chunk.citation.strip("[]")
ctx.cite(label)
text = " ".join(x for x in sentences(h.passage) if not _DOSE.search(x))[:PASSAGE_CHARS]
if text:
out.append({"source": label, "section": h.chunk.section, "text": text})
return {"results": out, "found": bool(out)}
def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name)
if entry is None:
return {"found": False, "drug": name}
ctx.cite(entry.source)
return {
"found": True,
"inn": entry.inn,
"class": entry.drug_class,
"composition": entry.composition.model_dump() if entry.composition else None,
"contraindications": entry.contraindications,
"interactions": [i.text for i in entry.interactions],
"pregnancy": entry.pregnancy,
"source": entry.source,
}
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name) if name.strip() else None
if entry is None:
# "мои таблетки" / no name → the current psychiatric prescription from the record.
rec = await ctx.record()
current = [find_drug(p.drug) for p in (rec.current_prescriptions if rec else [])]
entry = next((e for e in current if e is not None), None) if not find_drug(name) else None
if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record()
if rec is None:
return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта")
db = load_db()
findings = check_patient(entry, rec, db)
comp = entry.composition
intolerances = [a.name for a in rec.allergies]
intolerance_check = None
if comp is not None and intolerances:
hit = {t for a in rec.allergies for t in a.tags} & set(comp.excipient_tags)
intolerance_check = f"Непереносимость из карты ({', '.join(intolerances)}): " + (
"вещество ЕСТЬ в составе."
if hit
else "в примере состава этого вещества нет; состав зависит от производителя."
)
return {
"found": True,
"inn": entry.inn,
"record": True,
"findings": [f.model_dump() for f in findings],
"intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
"general_interactions": [i.text for i in entry.interactions], # incl. alcohol — for questions about it
"contraindications": entry.contraindications,
"note": "Пустой findings не означает, что препарат безопасен: решение о приёме принимает врач.",
}
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
+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
+257
View File
@@ -0,0 +1,257 @@
"""Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005).
`run_until(now)` replays, in time order, every event that is due by `now` and still fresh
(`config/scheduler.yaml`):
* med_reminder — a `doses` row (pending) per prescription time, then `system:med_reminder {dose_id, drug, dose}`;
* missed — a reminded dose still pending `missed_after_hours` later → status `missed` → monitoring;
* daily_checkin — `system:daily_checkin` at the check-in time;
* scale_due — scales due by the module schedules (`config/modules.yaml`) → `system:scale_due`;
* evaluation — once a day `MonitoringService.after_data_change()`: catches silence > N days (rule
`no_contact_3d`) and missed doses even when the patient writes nothing.
The rules decide (ADR-007); the scheduler only turns their dialog actions into system events for the engine
(`reach_out` → `system:reach_out`, `schedule_scale` → `system:scale_due`). Replies go to `integrations.outbox`.
Idempotent: a delivered event is stored in `scheduler_events` under a unique key and never sent again; a failed
delivery is not stored and is retried on the next run while it is fresh (at least once). Only patients with
consent (and adult) are contacted; while a crisis is open no reminders, check-ins or scales are sent.
Time comes only from the arguments / `app.core.clock` (guardrail #4).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.modules import due_scales, load_modules
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DialogState, Dose, PatientProfile, Prescription
from app.db.scheduler import SchedulerEvent
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.integrations.base import integrations
from app.services.contracts import get_monitoring
log = logging.getLogger(__name__)
# ------------------------------------------------------------------------------------------------ config
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class DosesConfig(_Strict):
late_hours: float = Field(gt=0)
missed_after_hours: float = Field(gt=0)
class Window(_Strict):
time: time
late_hours: float = Field(gt=0)
class SchedulerConfig(_Strict):
enabled: bool = True
tick_seconds: float = Field(gt=0)
doses: DosesConfig
daily_checkin: Window
scales: Window
evaluation: Window
@lru_cache
def load_config(path: Path | None = None) -> SchedulerConfig:
path = path or get_settings().config_dir / "scheduler.yaml"
return SchedulerConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------------ jobs
# Same due time → this order: the dose before the check-in that asks about it.
_ORDER = {"evaluation": 0, "missed": 1, "med_reminder": 2, "scale_due": 3, "daily_checkin": 4}
@dataclass(order=True)
class Job:
due: datetime
rank: int
token: str
key: str
kind: str = field(compare=False)
params: dict[str, Any] = field(default_factory=dict, compare=False)
def _job(due: datetime, token: str, key: str, kind: str, **params: Any) -> Job:
return Job(due=due, rank=_ORDER[kind], token=token, key=key, kind=kind, params=params)
def _at(day: date, t: time, now: datetime) -> datetime:
return datetime.combine(day, t, tzinfo=now.tzinfo)
def _fresh(due: datetime, now: datetime, late_hours: float) -> bool:
return due <= now < due + timedelta(hours=late_hours)
def _window(day: date, w: Window, now: datetime) -> datetime | None:
due = _at(day, w.time, now)
return due if _fresh(due, now, w.late_hours) else None
async def _dose_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
jobs: list[Job] = []
today = now.date()
for rx in (await s.scalars(select(Prescription).where(Prescription.token == p.token))).all():
start = rx.start_date or p.therapy_start
for day in (today - timedelta(days=1), today): # a fresh dose is never older than a day
if (start and day < start) or (rx.end_date and day > rx.end_date):
continue
for raw in (rx.schedule or {}).get("times", []):
planned = _at(day, time.fromisoformat(raw), now)
if _fresh(planned, now, cfg.doses.late_hours):
key = f"dose:{rx.id}:{planned.isoformat(timespec='minutes')}"
jobs.append(_job(planned, p.token, key, "med_reminder", rx_id=rx.id, planned_at=planned))
return jobs
async def _missed_jobs(s: AsyncSession, token: str, now: datetime, cfg: SchedulerConfig) -> list[Job]:
after = timedelta(hours=cfg.doses.missed_after_hours)
stmt = select(Dose).where(Dose.token == token, Dose.status == "pending", Dose.planned_at <= now - after)
return [_job(d.planned_at + after, token, f"missed:{d.id}", "missed", dose_id=d.id) for d in await s.scalars(stmt)]
async def _scale_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
due_at = _window(now.date(), cfg.scales, now)
if due_at is None:
return []
last: dict[str, date] = {}
for r in await repo.list_scale_results(s, p.token):
if r.complete:
last[r.scale] = r.ts.astimezone(now.tzinfo).date()
if p.therapy_start:
# A scale never taken counts from the visit where therapy started, not as overdue on day 0.
for mod in load_modules().modules.values():
for entry in mod.schedule:
last.setdefault(entry.scale, p.therapy_start)
therapy_day = (now.date() - p.therapy_start).days if p.therapy_start else 0
scales = due_scales(set(p.modules or []), therapy_day, last, now.date())
return [_job(due_at, p.token, f"scale:{sc}:{now.date()}", "scale_due", scale=sc) for sc in scales]
async def collect(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
"""Due and fresh, not yet delivered events of all contactable patients, in time order."""
cfg = cfg or load_config()
jobs: list[Job] = []
async with get_clinical_session() as s:
for p in (await s.scalars(select(PatientProfile).order_by(PatientProfile.token))).all():
state = await s.get(DialogState, p.token)
data = state.data if state is not None else {}
if data.get("consent") is not True or data.get("adult") is not True:
continue
mine = await _missed_jobs(s, p.token, now, cfg)
if (due := _window(now.date(), cfg.evaluation, now)) is not None:
mine.append(_job(due, p.token, f"eval:{now.date()}", "evaluation"))
if not (data.get("crisis") or {}).get("open"):
mine += await _dose_jobs(s, p, now, cfg)
mine += await _scale_jobs(s, p, now, cfg)
if (due := _window(now.date(), cfg.daily_checkin, now)) is not None:
mine.append(_job(due, p.token, f"checkin:{now.date()}", "daily_checkin"))
done = set((await s.scalars(select(SchedulerEvent.key).where(SchedulerEvent.token == p.token))).all())
jobs += [j for j in mine if j.key not in done]
return sorted(jobs)
# ------------------------------------------------------------------------------------------------ delivery
async def _system(token: str, kind: str, **params: Any) -> None:
replies: list[Reply] = await get_engine().handle(
token,
Event(type="system", system=kind, params=params), # type: ignore[arg-type]
)
if replies:
await integrations.outbox.push(token, replies)
async def _after_rules(token: str) -> None:
"""Monitoring pass outside a dialog: hand the rules' dialog actions to the engine as system events."""
actions: list[Action] = await get_monitoring().after_data_change(token)
for a in actions:
if a.type == "reach_out":
await _system(token, "reach_out", **a.params)
elif a.type == "schedule_scale" and a.params.get("scale"):
await _system(token, "scale_due", scale=a.params["scale"], scheduled=bool(a.params.get("scheduled")))
elif a.type == "crisis":
log.warning("scheduler crisis action token=%s code=%s", token, a.code) # alert already persisted
async def _ensure_dose(token: str, rx_id: int, planned_at: datetime) -> tuple[int, Prescription]:
async with get_clinical_session() as s:
rx = await s.get(Prescription, rx_id)
assert rx is not None
dose = await s.scalar(
select(Dose).where(Dose.token == token, Dose.prescription_id == rx_id, Dose.planned_at == planned_at)
)
if dose is None:
dose = Dose(token=token, prescription_id=rx_id, planned_at=planned_at, status="pending")
s.add(dose)
await s.flush()
return dose.id, rx
async def _mark_missed(dose_id: int) -> bool:
async with get_clinical_session() as s:
dose = await s.get(Dose, dose_id)
if dose is None or dose.status != "pending":
return False # answered meanwhile
dose.status = "missed"
return True
async def _run(job: Job, now: datetime) -> None:
if job.kind == "med_reminder":
dose_id, rx = await _ensure_dose(job.token, job.params["rx_id"], job.params["planned_at"])
await _system(job.token, "med_reminder", dose_id=dose_id, drug=rx.drug, dose=rx.dose)
elif job.kind == "missed":
if await _mark_missed(job.params["dose_id"]):
await _after_rules(job.token)
elif job.kind == "daily_checkin":
await _system(job.token, "daily_checkin")
elif job.kind == "scale_due":
await _system(job.token, "scale_due", scale=job.params["scale"], scheduled=True)
elif job.kind == "evaluation":
await _after_rules(job.token)
async def _done(job: Job, now: datetime) -> None:
async with get_clinical_session() as s:
s.add(SchedulerEvent(token=job.token, key=job.key, kind=job.kind, due_at=job.due, done_at=now))
async def run_until(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
"""Deliver every due event up to `now` in time order; returns the delivered jobs."""
delivered: list[Job] = []
for job in await collect(now, cfg):
try:
await _run(job, now)
except Exception as exc: # noqa: BLE001 — one patient's failure must not stop the others
log.error("scheduler job failed token=%s kind=%s error=%s", job.token, job.kind, type(exc).__name__)
continue
await _done(job, now)
delivered.append(job)
if delivered:
log.info("scheduler delivered=%s", [(j.token, j.kind) for j in delivered])
return delivered
+193 -7
View File
@@ -1,13 +1,199 @@
"""Scheduler on the (virtual) demo clock — stub until #13. Called from app.main lifespan."""
"""Scheduler on the demo clock: questionnaire jobs when that release is on, else the demo scheduler (#13).
One scheduler per database. PostgreSQL's advisory transaction lock serializes
workers; an asyncio lock does the same within a SQLite demonstration process.
Deliveries are at least once: the channel protocol offers no idempotency key.
"""
import asyncio
import logging
from contextlib import suppress
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.core.intake_questions import DIARY_KINDS
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
from app.core.questionnaire import load_policy
from app.db import repo
from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.integrations.base import integrations
from app.scheduler import demo
from app.services import monitoring
from app.services import questionnaire as service
log = logging.getLogger(__name__)
_worker = None
_lock = asyncio.Lock()
async def start() -> None:
"""Start periodic ticking (#13)."""
async def plan():
now = clock.now()
p = load_runtime()
local = now.astimezone(ZoneInfo(p.timezone))
async with get_clinical_session() as session:
profiles = (await session.scalars(select(PatientProfile))).all()
for profile in profiles:
state = await repo.get_dialog_state(session, profile.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue
rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None:
continue
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
last_activity = max([*diaries, initial.day])
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
continue
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
if local.hour >= hour and kind not in done:
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
last = {}
for row in rows:
if row.status != "complete":
continue
for sid in ("phq9", "pcl5", "rus_audit_s"):
if row.details.get("totals", {}).get(sid, {}).get("complete"):
last[sid] = row.completed_at.astimezone(ZoneInfo(p.timezone)).date()
route = initial.details["decision"]["primary_route"]
prescriptions = await integrations.prescriptions.get_prescriptions(profile.token)
changes = [
v.start_date
for v in prescriptions
if v.start_date and v.start_date <= local.date() and (v.end_date is None or v.end_date >= local.date())
]
if profile.therapy_start and profile.therapy_start <= local.date():
changes.append(profile.therapy_start)
closed = (state.data.get("crisis") or {}).get("closed_at")
crisis_date = datetime.fromisoformat(closed).astimezone(ZoneInfo(p.timezone)).date() if closed else None
scales = due_scales(
today=local.date(),
enrolled=initial.day,
last=last,
route="self_help" if route == "self_help" else "specialist",
medication_changed=max(changes) if changes else None,
after_crisis=crisis_date,
policy=p,
)
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
totals = initial.details.get("totals", {})
modules = set(profile.modules or [])
policy = load_policy()
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
wanted = {
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
"rus_audit_s": True,
}
for sid in scales:
if wanted[sid]:
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
async def stop() -> None:
"""Stop the scheduler (#13)."""
async def deliver():
async with get_clinical_session() as session:
task_ids = list(
(
await session.scalars(
select(QuestionnaireTask.id)
.where(
QuestionnaireTask.sent_at.is_(None),
QuestionnaireTask.status != "cancelled",
QuestionnaireTask.due_at <= clock.now(),
)
.order_by((QuestionnaireTask.kind != "cssrs_since_contact"), QuestionnaireTask.id)
)
).all()
)
for task_id in task_ids:
async with get_clinical_session() as session:
task = await session.get(QuestionnaireTask, task_id)
state = await repo.get_dialog_state(session, task.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
task.status, task.delivery = "cancelled", []
continue
if (state.data.get("crisis") or {}).get("open"):
continue
if task.status == "done" and not task.delivery:
continue
token = task.token
payload = task.delivery
if not payload and state.state not in ("idle", "intake.collect"):
continue
if not payload:
await get_engine().handle(
token, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
# Serialize delivery with withdrawal of consent and user answers.
async with get_clinical_session() as session:
await service.lock_patient(session, token)
state = await repo.get_dialog_state(session, token)
task = await session.get(QuestionnaireTask, task_id)
if not state or state.data.get("consent") is not True or not task.delivery:
continue
try:
await asyncio.wait_for(
integrations.outbox.push(token, [Reply.model_validate(r) for r in task.delivery]), timeout=10
)
except Exception as exc:
log.warning("questionnaire outbox error=%s", type(exc).__name__)
else:
task.sent_at = clock.now()
async def run_until_now() -> None:
"""Replay all due events up to `app.core.clock.now()` in order, idempotently (#13)."""
async def run_until_now():
async with _lock, get_clinical_session() as guard:
if guard.bind.dialect.name == "postgresql" and not await guard.scalar(
text("SELECT pg_try_advisory_xact_lock(520053)")
):
return
await service.dispatch_notices()
if not intake_enabled():
# Questionnaire release off → the demo scheduler (doses, check-ins, scales, silence) runs instead.
if demo.load_config().enabled:
await demo.run_until(clock.now())
return
await plan()
await deliver()
await service.dispatch_notices()
async def _run():
while True:
try:
await run_until_now()
except Exception as exc:
log.error("questionnaire scheduler error=%s", type(exc).__name__)
await asyncio.sleep(demo.load_config().tick_seconds)
async def start():
global _worker
try:
await monitoring.init_clock_persistence()
except (OSError, SQLAlchemyError) as exc:
log.error("clock persistence unavailable error=%s", type(exc).__name__)
if _worker is None and (intake_enabled() or demo.load_config().enabled):
_worker = asyncio.create_task(_run())
async def stop():
global _worker
if _worker is not None:
_worker.cancel()
with suppress(asyncio.CancelledError):
await _worker
_worker = None
+157
View File
@@ -0,0 +1,157 @@
"""Monitoring service (#38): patient snapshot → deterministic rules → persisted alerts (ADR-007).
`after_data_change(token)` is called by the dialog engine after it commits clinical data (and by the scheduler
on silence). It builds a `PatientSnapshot`, runs `app.core.rules.evaluate`, then in one transaction:
* writes every `alert`/`crisis` action to `alerts` (a crisis is an `acute` alert) and, for a crisis,
an audit record `crisis_acute` (guardrail #6);
* applies `enhanced_monitoring` to the profile;
* refreshes the cached `status_color` of the profile (ADR-006; the source of truth stays the open alerts).
After the commit the doctor is notified through `integrations.notifier` (a crisis also goes to the doctor on
duty), and only the *dialog* actions are returned to the engine — it never creates alerts itself.
A crisis rule whose acute alert is still open stays silent: the crisis script is already running and the
doctor already knows, so it is neither restarted nor re-alerted (`rules.evaluate` never suppresses crises).
The virtual clock offset is persisted in `clock_state` (ADR-005) by `init_clock_persistence()`.
"""
import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
from app.core.rules import Action, evaluate, patient_status
from app.core.types import AlertLevel
from app.db import repo
from app.db.audit import audit
from app.db.session import get_clinical_session
from app.integrations.base import integrations
from app.services.contracts import register_monitoring
from app.services.snapshot import build_snapshot
log = logging.getLogger(__name__)
SYSTEM_ACTOR = "system"
# Notification address of the doctor on duty: acute alerts must reach someone even when the attending doctor
# is off (SPEC: crisis → immediate escalation). The notifier maps it to the duty channel.
ON_DUTY_DOCTOR = "on-duty"
# Persisted by the service itself, never passed to the dialog engine.
_SERVICE_ACTIONS = frozenset({"alert", "enhanced_monitoring"})
async def refresh_status(session: AsyncSession, token: str) -> str:
"""Recompute and cache the dashboard colour from open alerts (also call it after an ack)."""
alerts = await repo.list_alerts(session, token=token, open_only=True)
status = patient_status(AlertLevel[a.level.upper()] for a in alerts)
await repo.upsert_profile(session, token=token, status_color=status.color)
return status.color
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
return [a for a in actions if a.code not in running]
class RuleMonitoring:
async def after_data_change(self, token: str) -> list[Action]:
now = clock.now()
notify: list[tuple[str, str, str]] = [] # (doctor_id, level, text) — sent after the commit
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
snapshot = await build_snapshot(s, token, now, profile=profile)
if profile is None or snapshot is None:
return []
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
actions = _drop_running_crises(evaluate(snapshot), open_acute)
for action in actions:
if action.type == "enhanced_monitoring":
profile.enhanced_monitoring = True
if not action.creates_alert or action.level is None:
continue
alert = await repo.add_alert(
s,
token=token,
level=action.level,
code=action.code,
reason=action.reason_ru,
ts=now,
params=action.params,
)
level = action.level.name.lower()
notify.append((profile.doctor_id, level, action.reason_ru))
if action.type == "crisis":
await audit(
s,
actor=SYSTEM_ACTOR,
action="crisis_acute",
token=token,
details={"alert_id": alert.id, "code": action.code},
ts=now,
)
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
await refresh_status(s, token)
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
for doctor_id, level, text in notify:
await _notify(doctor_id, level, text, token)
return [a for a in actions if a.type not in _SERVICE_ACTIONS]
async def is_in_crisis(self, token: str) -> bool:
async with get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token, open_only=True)
return any(a.level == "acute" for a in alerts)
async def _notify(doctor_id: str, level: str, text: str, token: str) -> None:
# The alert is already committed and visible on the dashboard; a failing channel must not undo it.
try:
await integrations.notifier.send(doctor_id, level, text, token) # type: ignore[arg-type]
except Exception as exc: # noqa: BLE001 — any channel error
log.warning("notify failed token=%s level=%s error=%s", token, level, type(exc).__name__)
register_monitoring(RuleMonitoring)
# ---------------------------------------------------------------------------------- demo clock persistence
_writes: set[asyncio.Task[None]] = set()
async def _write_offset() -> None:
# Writes the offset current at write time, so out-of-order tasks still leave the latest value stored.
try:
async with get_clinical_session() as s:
await repo.save_clock_offset(s, clock.offset().total_seconds())
except (OSError, SQLAlchemyError) as exc:
log.warning("clock offset not saved error=%s", type(exc).__name__)
def _save_offset(_: timedelta) -> None:
"""Sync hook for `clock.register_persistence`: schedules the DB write on the running loop."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
log.warning("clock offset not saved: no running event loop")
return
task = loop.create_task(_write_offset())
_writes.add(task)
task.add_done_callback(_writes.discard)
async def flush_clock_writes() -> None:
"""Wait for pending offset writes (tests, shutdown)."""
if _writes:
await asyncio.gather(*list(_writes))
async def init_clock_persistence() -> datetime:
"""Restore the virtual clock offset from `clock_state` and save every later change there."""
async with get_clinical_session() as s:
stored = timedelta(seconds=await repo.load_clock_offset(s))
clock.register_persistence(lambda: stored, _save_offset)
log.info("clock offset restored seconds=%s", int(stored.total_seconds()))
return clock.now()
+277
View File
@@ -0,0 +1,277 @@
"""Transactional questionnaire storage and a durable doctor notification queue.
Notifier acceptance is not delivery or a doctor's acknowledgement. At-least-once
retries can duplicate delivery if a process dies after sending but before commit.
"""
import asyncio
from dataclasses import asdict
from datetime import timedelta
from uuid import uuid4
from zoneinfo import ZoneInfo
from sqlalchemy import select
from app.core import clock
from app.core.intake_questions import content, cssrs_answers, group_answers
from app.core.intake_runtime import load_runtime, month_before
from app.core.questionnaire import evaluate_intake, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale, score
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert, DialogState
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.db.session import get_clinical_session
from app.integrations.base import MemoryNotifier, integrations
async def records(session, token):
return list(
(
await session.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at, QuestionnaireRecord.id)
)
).all()
)
async def create_record(ctx, kind, *, task=None):
profile = await ctx.profile()
runtime = load_runtime()
prior = next(
(r for r in reversed(await records(ctx.session, ctx.token)) if r.kind == "intake" and r.status == "complete"),
None,
)
sex = profile.sex if profile else None
if sex is None and prior:
sex = prior.details.get("sex")
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
row = QuestionnaireRecord(
id=uuid4().hex,
token=ctx.token,
kind=kind,
version=content()["version"],
policy_version=load_policy().version,
started_at=ctx.now,
day=day,
status="active",
answers={},
details={
"runtime_version": runtime.version,
"modules": list(profile.modules or []) if profile else [],
"sex": sex,
"medication_active": bool(
(profile and profile.therapy_start and profile.therapy_start <= ctx.now.date())
or (prior and prior.answers.get("psychotropics") == 1)
),
"suicide_monitoring": bool(profile and profile.enhanced_monitoring),
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {},
"task_id": task.id if task else None,
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
"day_index": (day - prior.day).days if prior else 0,
},
)
ctx.session.add(row)
await ctx.session.flush()
if task:
task.record_id = row.id
task.status = "active"
return row
def save_answer(row, question, value, now, *, derived=False):
"""Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day
starts = {
"past_month": month_before(day),
"past_3_months": month_before(day, 3),
"past_week": day - timedelta(days=7),
"past_day": day - timedelta(days=1),
"последние 2 недели": day - timedelta(days=14),
"последний месяц": month_before(day),
"последняя неделя": day - timedelta(days=7),
"прошлая неделя": day - timedelta(days=7),
"последние 12 месяцев": month_before(day, 12),
}
row.answers = {**row.answers, question.id: value}
if question.id == "sex" and value in ("m", "f"):
row.details = {**row.details, "sex": value}
row.details = {
**row.details,
"answer_metadata": {
**row.details.get("answer_metadata", {}),
question.id: {
"answered_at": now.isoformat(),
"period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(),
"source": "derived" if derived else "patient",
"derived": derived,
},
},
}
def decision(row):
a = row.answers
sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None)
return evaluate_intake(
phq9=group_answers(a, "phq9"),
tsq=group_answers(a, "tsq"),
pcl5=group_answers(a, "pcl5"),
alcohol=group_answers(a, "rus_audit"),
cssrs=cssrs_answers(a),
sex=sex,
trauma_exposure=bool(a["trauma"]) if "trauma" in a else None,
traumatic_brain_injury=a.get("tbi") == 1,
psychotropics=a.get("psychotropics") == 1,
)
def summary(row):
a = row.answers
totals = {}
for sid in ("phq9", "tsq", "pcl5"):
result = score(sid, group_answers(a, sid))
totals[sid] = {
"complete": result.complete,
"total": result.total if result.complete else None,
"answered": result.answered,
}
sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None)
for sid in ("rus_audit_s", "rus_audit"):
values = {k: a[k] for k in load_scale(sid).item_ids if k in a}
totals[sid] = asdict(score_alcohol(values, short=sid.endswith("_s"), sex=sex))
totals["cssrs_recent"] = asdict(score_cssrs_recent(cssrs_answers(a)))
return totals
async def finish(ctx, row, status, *, extra=None):
row.status = status
row.completed_at = ctx.now if status == "complete" else None
row.details = {**row.details, **(extra or {})}
if row.details.get("task_id"):
task = await ctx.session.get(QuestionnaireTask, row.details["task_id"])
if task:
task.status = "done"
async def alert(session, token, key, code, now, *, level=AlertLevel.MODERATE, params=None):
existing = await session.scalar(
select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.event_key == key)
)
if existing:
return existing
row = await repo.add_alert(session, token=token, level=level, code=code, reason=code, ts=now, params=params)
notice = QuestionnaireNotice(token=token, event_key=key, alert_id=row.id, created_at=now)
session.add(notice)
await session.flush()
return notice
async def cancel_pending(session, token):
for row in await records(session, token):
if row.status in ("active", "paused"):
row.status = "cancelled"
tasks = await session.scalars(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token, QuestionnaireTask.status.in_(("pending", "active"))
)
)
for task in tasks:
task.status = "cancelled"
task.delivery = []
notices = await session.scalars(
select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.status == "pending")
)
for notice in notices:
notice.status = "cancelled"
async def dispatch_notices(token=None):
now = clock.now()
async with get_clinical_session() as session:
stmt = (
select(QuestionnaireNotice.id, QuestionnaireNotice.token)
.where(QuestionnaireNotice.status == "pending")
.order_by(QuestionnaireNotice.id)
.limit(100)
)
if token:
stmt = stmt.where(QuestionnaireNotice.token == token)
candidates = (await session.execute(stmt)).all()
for notice_id, patient_token in candidates:
async with get_clinical_session() as session:
# Same lock order as withdrawal: patient first, then notice.
await lock_patient(session, patient_token)
notice = await session.scalar(
select(QuestionnaireNotice).where(QuestionnaireNotice.id == notice_id).with_for_update()
)
if notice.status != "pending":
continue
if notice.attempted_at and now < notice.attempted_at + timedelta(
minutes=min(60, 2 ** min(notice.attempts, 6))
):
continue
state = await repo.get_dialog_state(session, notice.token)
if state is None or state.data.get("consent") is not True:
notice.status = "cancelled"
continue
profile = await repo.get_profile(session, notice.token)
if not profile or not profile.doctor_id:
continue
item = await session.get(Alert, notice.alert_id)
notice.attempted_at, notice.attempts = now, notice.attempts + 1
try:
await asyncio.wait_for(
integrations.notifier.send(profile.doctor_id, item.level, item.code, token=notice.token), timeout=10
)
except Exception as exc:
notice.error_code = type(exc).__name__[:80]
else:
notice.error_code = None
notice.accepted_at = now
notice.status = (
"test_sink" if isinstance(integrations.notifier, MemoryNotifier) else "accepted_by_adapter"
)
async def queue_task(session, token, kind, key, now):
existing = await session.scalar(
select(QuestionnaireTask).where(QuestionnaireTask.token == token, QuestionnaireTask.task_key == key)
)
if existing:
return existing
# Only one outstanding administration per form, however many ticks were missed.
existing = await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
if existing:
return existing
row = QuestionnaireTask(token=token, kind=kind, task_key=key, due_at=now)
session.add(row)
await session.flush()
return row
async def outstanding_task(session, token, kind):
return await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction.
await session.scalar(select(DialogState).where(DialogState.token == token).with_for_update())
+93
View File
@@ -0,0 +1,93 @@
"""Patient snapshot for the rules engine, assembled from the clinical DB (ADR-007).
Maps storage names to `app.core.rules` names (`sleep_bucket` → `sleep_hours_bucket`, `alcohol_bucket` →
`alcohol`), derives age from `birth_year` and the therapy day from `therapy_start`. Time comes in as `as_of`
(from `app.core.clock`), never from the wall clock. Must not import `app.db.identity` (guardrail #5).
"""
from datetime import datetime, timedelta
from typing import Any, get_args
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rules import (
CssrsRisk,
DailyCheckin,
DoseRecord,
Module,
PatientSnapshot,
ScalePoint,
)
from app.db import repo
from app.db.clinical import Dose, Message, PatientProfile
# Risk screens are stored as scale results but reach the rules as `cssrs_risk`, not as scale points.
CSSRS_SCALES = frozenset({"cssrs_screen", "cssrs"})
# A C-SSRS result counts as the current risk for this long (same window as PHQ-9 item 9 and the dialog's
# C-SSRS cooldown): one old "high" must not restart the crisis protocol after the doctor has acted on it.
CSSRS_RECENT = timedelta(hours=24)
# Unknown sex/age fall back to the more cautious option: lower AUDIT-C cut-off, young-adult monitoring.
_UNKNOWN_SEX = "f"
_UNKNOWN_AGE = 0
_MODULES = frozenset(get_args(Module))
_CSSRS_RISKS = frozenset(get_args(CssrsRisk))
def _cssrs_risk(results: list[Any], as_of: datetime) -> CssrsRisk | None:
recent = [r for r in results if r.scale in CSSRS_SCALES and as_of - CSSRS_RECENT < r.ts <= as_of]
if not recent:
return None
severity = recent[-1].severity
return severity if severity in _CSSRS_RISKS else None
async def build_snapshot(
session: AsyncSession, token: str, as_of: datetime, *, profile: PatientProfile | None = None
) -> PatientSnapshot | None:
"""Snapshot of everything the rules may look at; None if the patient has no clinical profile."""
profile = profile or await repo.get_profile(session, token)
if profile is None:
return None
checkins = [
DailyCheckin(
day=c.day,
mood=c.mood,
med_taken=c.med_taken,
sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type]
alcohol=c.alcohol_bucket, # type: ignore[arg-type]
side_effects=list(c.side_effects or []),
quit_intent=bool(c.quit_intent),
nightmares=c.nightmares,
)
for c in await repo.list_checkins(session, token)
]
results = [r for r in await repo.list_scale_results(session, token) if r.complete]
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
last_in = await session.scalar(
select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in")
)
state = await repo.get_dialog_state(session, token)
facts = (state.data.get("facts") or {}) if state is not None else {}
return PatientSnapshot(
token=token,
as_of=as_of,
age=as_of.year - profile.birth_year if profile.birth_year else _UNKNOWN_AGE,
sex=profile.sex if profile.sex in ("m", "f") else _UNKNOWN_SEX, # type: ignore[arg-type]
modules={m for m in profile.modules or [] if m in _MODULES}, # type: ignore[misc]
therapy_day=(as_of.date() - profile.therapy_start).days if profile.therapy_start else 0,
checkins=checkins,
scale_results=[
ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled)
for r in results
if r.scale not in CSSRS_SCALES
],
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
cssrs_risk=_cssrs_risk(results, as_of),
weapon_access=facts.get("weapon_access"),
last_patient_message_at=last_in,
open_alert_codes=await repo.open_alert_codes(session, token),
)
+1
View File
@@ -7,3 +7,4 @@
- `modules.yaml` — модули и расписания шкал
- `lexicon/risk_ru.yaml` — лексикон риска
- `texts/` — тексты для пациента (кризисный скрипт, согласие, перефразировки)
- `rag/` — база знаний (ADR-011): `sources.yaml` (документы, параметры поиска, синонимы) и `corpus/*.txt`, собранные из PDF в `docs/source/rag/`
+61
View File
@@ -0,0 +1,61 @@
# Verbatim source content; only whitespace/line wraps normalized. Not enabled in the legacy dialog.
id: cssrs_recent_ru
version: source-2026-10-03
title: КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА
subtitle: Screen Version - Recent
period_label_original: Последние 1 месяца
initial_instruction: Задайте вопросы 1 и 2
branch_instruction: ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6.
options:
- value: 1
label: Да
- value: 0
label: Нет
items:
- id: q1
text: Возникало ли у Вас желание умереть или заснуть и не просыпаться?
interviewer_guidance: null
recall_period: past_month
yes_risk: low
- id: q2
text: Думали ли Вы в самом деле о том, чтобы убить себя?
interviewer_guidance: null
recall_period: past_month
yes_risk: low
- id: q3
text: Думали ли Вы о том, как Вы могли бы это сделать?
interviewer_guidance: 'Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения
он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал
конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства,
но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так
и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело
до конца”.'
recall_period: past_month
yes_risk: moderate
- id: q4
text: Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними?
interviewer_guidance: 'Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать
в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно
не стану ничего предпринимать”.'
recall_period: past_month
yes_risk: high
- id: q5
text: Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить
этот план?
interviewer_guidance: null
recall_period: past_month
yes_risk: high
- id: q6_lifetime
text: Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить
с собой?
interviewer_guidance: 'Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание
или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять
или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались
застрелиться, порезались, пытался повеситься и т. д.'
recall_period: lifetime
yes_risk: moderate
- id: q6_recent
text: ЕСЛИ ДА, то было ли это в последние 3 месяца?
interviewer_guidance: null
recall_period: past_3_months
yes_risk: high
+84
View File
@@ -0,0 +1,84 @@
# Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3.
# Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds
# exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only.
# Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran».
version: diary-v3-2026-10-04
# Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00.
morning:
- id: sleep_hours
source: Consensus Sleep Diary
text: Сколько часов Вы спали этой ночью?
numeric: true
max: 24
- id: sleep_quality
source: Consensus Sleep Diary
text: Как Вы оцениваете качество своего сна?
options:
- {value: 0, label: Очень плохо}
- {value: 1, label: Плохо}
- {value: 2, label: Удовлетворительно}
- {value: 3, label: Хорошо}
- {value: 4, label: Очень хорошо}
- id: alcohol_portions
source: принцип Timeline Followback; порция по RUS-AUDIT
text: |-
Сколько стандартных порций алкоголя Вы выпили вчера?
Одна порция — 100 мл вина, 250 мл пива или 30 мл крепкого алкоголя. Если не пили — введите 0.
numeric: true
max: 50
evening:
- id: mood
source: ежедневная оценка настроения (недельные средние коррелируют с PHQ-9)
text: |-
Как Вы оцениваете своё настроение сегодня?
0 — самое плохое, 10 — самое хорошее.
options:
- {value: 0, label: '0'}
- {value: 1, label: '1'}
- {value: 2, label: '2'}
- {value: 3, label: '3'}
- {value: 4, label: '4'}
- {value: 5, label: '5'}
- {value: 6, label: '6'}
- {value: 7, label: '7'}
- {value: 8, label: '8'}
- {value: 9, label: '9'}
- {value: 10, label: '10'}
- id: medication
source: КР ПТСР — дневник приёма лекарств
text: Приняли ли Вы сегодня лекарства, назначенные врачом?
options:
- {value: 'yes', label: Да}
- {value: 'no', label: Нет}
- {value: not_prescribed, label: Лекарства не назначены}
- id: medication_reason
source: КР ПТСР — дневник приёма лекарств
text: Почему не приняли?
options:
- {value: forgot, label: Забыл(а)}
- {value: side_effects, label: Побочные эффекты}
- {value: declined, label: Решил(а) не принимать}
- {value: ran_out, label: Закончились}
# Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer
# options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9.
# Item 9 ≥1 → stop and the crisis protocol (stage 1 rule).
depression:
form: phq9
items_per_day: 3
question: 'Насколько сегодня Вас беспокоило:'
options:
- {value: 0, label: Совсем нет}
- {value: 1, label: Немного}
- {value: 2, label: Умеренно}
- {value: 3, label: Сильно}
# PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the
# past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»).
ptsd:
form: pcl5
items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18]
question: 'Насколько сильно сегодня Вас беспокоили:'
+167
View File
@@ -0,0 +1,167 @@
version: mvp-v2-2026-10-03
forms:
phq9:
id: phq9
title: Опросник здоровья пациента (PHQ-9)
recall_period: последние 2 недели
instruction: Как часто за последние 2 недели Вас беспокоили следующие проблемы?
license_note: PHQ-9 (Pfizer) — свободное использование, разрешения не требуется.
translation_note: Стандартная русская версия PHQ-9 (MAPI). Финальную формулировку утверждает психиатр.
options:
- value: 0
label: Ни разу
- value: 1
label: Несколько дней
- value: 2
label: Более недели
- value: 3
label: Почти каждый день
items:
- id: phq9_1
text: Вам не хотелось ничего делать
- id: phq9_2
text: У Вас было плохое настроение, Вы были подавлены или испытывали чувство безысходности
- id: phq9_3
text: Вам было трудно заснуть, у Вас был прерывистый сон, или Вы слишком много спали
- id: phq9_4
text: Вы были утомлены, или у Вас было мало сил
- id: phq9_5
text: У Вас был плохой аппетит, или Вы переедали
- id: phq9_6
text: 'Вы плохо о себе думали: считали себя неудачником (неудачницей), или были в себе разочарованы, или считали,
что подвели свою семью'
- id: phq9_7
text: Вам было трудно сосредоточиться (например, на чтении газеты или на просмотре телепередач)
- id: phq9_8
text: Вы двигались или говорили настолько медленно, что окружающие это замечали? Или, наоборот, Вы были настолько
суетливы или взбудоражены, что двигались гораздо больше обычного
- id: phq9_9
text: Вас посещали мысли о том, что Вам лучше было бы умереть, или о том, чтобы причинить себе какой-нибудь
вред
buttons_only: true
range:
min: 0
max: 27
severity:
- min: 0
max: 4
code: minimal
label: Минимальная
- min: 5
max: 9
code: mild
label: Лёгкая
- min: 10
max: 14
code: moderate
label: Умеренная
- min: 15
max: 19
code: moderately_severe
label: Умеренно-тяжёлая
- min: 20
max: 27
code: severe
label: Тяжёлая
flags:
- code: phq9_item9_positive
when:
item: phq9_9
op: '>='
value: 1
pcl5:
id: pcl5
title: Контрольный список симптомов ПТСР (PCL-5)
recall_period: последний месяц
instruction: 'Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового
события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант,
который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
Насколько сильно за последний месяц Вас беспокоили:'
license_note: PCL-5 — общественное достояние (VA National Center for PTSD); русская версия — лицензия на проверке.
translation_note: Рабочий перевод команды (демо-текст). Финальный русский перевод выбирает психиатр (SPEC §9).
Для еженедельного мониторинга используется та же шкала с периодом «последняя неделя».
options:
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
items:
- id: pcl5_1
text: Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
- id: pcl5_2
text: Повторяющиеся тяжёлые сны о стрессовом событии?
- id: pcl5_3
text: Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно
вернулись туда и переживаете его заново)?
- id: pcl5_4
text: Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
- id: pcl5_5
text: Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение,
затруднённое дыхание, потливость)?
- id: pcl5_6
text: Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
- id: pcl5_7
text: Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов
или ситуаций)?
- id: pcl5_8
text: Трудности с тем, чтобы вспомнить важные части стрессового события?
- id: pcl5_9
text: Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то
серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
- id: pcl5_10
text: Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
- id: pcl5_11
text: Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
- id: pcl5_12
text: Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
- id: pcl5_13
text: Ощущение отдалённости или оторванности от других людей?
- id: pcl5_14
text: Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость
или любовь к близким людям)?
- id: pcl5_15
text: Раздражительность, вспышки гнева или агрессивное поведение?
- id: pcl5_16
text: Чрезмерный риск или поступки, которые могут причинить Вам вред?
- id: pcl5_17
text: Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
- id: pcl5_18
text: Взвинченность или склонность легко вздрагивать?
- id: pcl5_19
text: Трудности с концентрацией внимания?
- id: pcl5_20
text: Трудности с засыпанием или прерывистый сон?
range:
min: 0
max: 80
severity:
- min: 0
max: 32
code: below_threshold
label: Ниже порога ПТСР
- min: 33
max: 49
code: probable
label: Вероятное ПТСР
- min: 50
max: 80
code: high
label: Выраженная симптоматика ПТСР
flags:
- code: pcl5_probable_ptsd
when:
total: true
op: '>='
value: 33
trauma_introduction: 'Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или
травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение
или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства
или самоубийства.'
trauma_filter: Случалось ли с Вами подобное событие?
+36
View File
@@ -0,0 +1,36 @@
version: intake-v3-2026-10-04
enabled: false
approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null
contact_review: null
timezone: Europe/Moscow
# Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00.
morning_hour: 8
evening_hour: 20
# Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`.
# Flag numbers are the team's candidates (PDF «Этап 2», p.4) — agree with the doctor.
norm_days: 14
week_days: 7
min_norm_values: 7
min_week_values: 4
mood_drop: 3 # weekly mood mean fell ≥3 below the norm → unscheduled PHQ-9
depression_item_rise: 1.0 # weekly mean of a PHQ-9 diary item rose ≥1 above its norm → PHQ-9 earlier
ptsd_sum_rise: 3.0 # weekly mean of the 4-item PCL-5 sum rose ≥3 above the norm → PCL-5 earlier
sleep_bad_days: 3 # <4 h or «очень плохо» three nights in a row → flag to the doctor
sleep_hours_below: 4
alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3)
alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT
adherence_below: 0.8 # share of days with medication taken over a week
missed_medication_days: 3
high_risk_silence_days: 2
medication_days: [7, 14, 28]
medication_repeat_days: 28
route_intervals:
after_crisis: {phq9: 7, pcl5: 14}
specialist: {phq9: 14, pcl5: 30}
self_help: {phq9: 28, pcl5: 28}
alcohol_repeat_days: 30
phq9_retriage_delta: 5
pcl5_retriage_delta: 10
+13
View File
@@ -0,0 +1,13 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03
approval: null
cssrs_positive_policy: pending
rus_audit_s_expand_from_zone: null
phq2_expand_at: 2
tsq_expand_at: 6
phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
+21
View File
@@ -0,0 +1,21 @@
# DEMO ONLY (INTAKE_DEMO=1 + demo clock, synthetic data). NOT a clinical approval: the real policy is
# config/questionnaire.yaml and stays closed until the doctor's decisions C01–C09 (docs/clinical/README.md).
# Values follow the MVP PDF so the demo can reach a result and stage 2:
# C01 → graded: low C-SSRS risk shows the safety plan and continues (PDF p.11, routing table);
# C02 → full RUS-AUDIT from zone II of RUS-AUDIT-S (WHO zones, Bunova 2022).
version: mvp-v2-demo-2026-10-04
approval:
reviewer: "DEMO — синтетические данные, не клиническое согласование"
decision_reference: "INTAKE_DEMO"
approved_at: "2026-10-04T00:00:00+03:00"
policy_version: mvp-v2-demo-2026-10-04
cssrs_positive_policy: graded
rus_audit_s_expand_from_zone: 2
phq2_expand_at: 2
tsq_expand_at: 6
phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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", "десенсибилизация и переработка травмы движениями глаз"]
- ["сопа", "синдром отмены антидепрессантов"]
- ["эст", "электросудорожная терапия"]
- ["ртмс", "транскраниальная магнитная стимуляция"]
- ["кошмар", "кошмарные сновидения"]
- ["бессонница", "инсомния", "нарушения сна"]
- ["флешбэк", "флэшбек", "навязчивые воспоминания"]
- ["суицид", "самоубийство", "суицидальные мысли"]
# Разговорная лексика пациента -> термины КР.
- ["таблетки", "препарат", "лекарство"]
- ["бросить", "отменять", "отменить", "отмена", "прекратить", "прекращение приема"]
- ["подействовать", "действовать", "помогать", "эффект", "улучшение"]
- ["антидепрессивный", "тимоаналептический"]
- ["привыкание", "зависимость"]
- ["спорт", "физическая активность", "физические упражнения", "физическая нагрузка"]
- ["побочка", "побочные эффекты", "нежелательные явления"]
- ["тошнит", "тошнота"]
- ["выпить", "спиртное", "алкоголь"]
- ["сколько длится", "продолжительность", "длительность"]
- ["психолог", "психотерапия"]
+58
View File
@@ -0,0 +1,58 @@
id: rus_audit
title: RUS-AUDIT
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
translation_note: "Текст из предоставленного PDF «Опросники для MVP», с.7–9. Самозаполнение полной интервью-формы не утверждено."
notes: "Пункт 2 требует таблицы стандартных порций из исходного бланка. Не заменять произвольным калькулятором. Автопереходы не утверждены."
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Реже одного раза в месяц"}
- {value: 2, label: "Ежемесячно"}
- {value: 3, label: "Еженедельно"}
- {value: 4, label: "Ежедневно или почти ежедневно"}
items:
- id: rus_audit_1
text: "Как часто Вы употребляете алкогольные напитки?"
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Раз в месяц или реже"}
- {value: 2, label: "2–4 раза в месяц"}
- {value: 3, label: "2–3 раза в неделю"}
- {value: 4, label: "4 раза в неделю и чаще"}
- id: rus_audit_2
text: "Сколько алкогольных напитков (стандартных порций) Вы употребляете в типичный день, когда выпиваете? Если в типичный день Вы употребляете несколько разных алкогольных напитков, то суммируйте число СП."
options:
- {value: 0, label: "1–2"}
- {value: 1, label: "3–4"}
- {value: 2, label: "5–6"}
- {value: 3, label: "7–9"}
- {value: 4, label: "10 и больше"}
- {id: rus_audit_3, text: "Как часто Вы употребляете как минимум 1,5 л пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"}
- {id: rus_audit_4, text: "Как часто за последние 12 месяцев Вы не могли остановиться, начав употреблять алкогольные напитки?"}
- {id: rus_audit_5, text: "Как часто за последние 12 месяцев из-за выпивки Вы не сделали то, что от Вас обычно ожидалось?"}
- {id: rus_audit_6, text: "Как часто за последние 12 месяцев Вам необходимо было выпить утром, чтобы прийти в себя после выпивки (опохмелиться)?"}
- {id: rus_audit_7, text: "Как часто за последние 12 месяцев Вы испытывали чувство вины или сожаления после выпивки?"}
- {id: rus_audit_8, text: "Как часто за последние 12 месяцев Вы были неспособны вспомнить, что было накануне, из-за того, что Вы выпивали?"}
- id: rus_audit_9
text: "Являлось ли Ваше употребление алкогольных напитков причиной травмы у Вас или у других людей?"
options: &history
- {value: 0, label: "Никогда"}
- {value: 2, label: "Да, более 12 месяцев назад"}
- {value: 4, label: "Да, в течение последних 12 месяцев"}
- id: rus_audit_10
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчёт употребления Вами алкоголя или советовал выпивать меньше?"
options: *history
range: {min: 0, max: 40}
severity:
- {min: 0, max: 40, code: unclassified, label: "Не выбран пол для интерпретации"}
by_sex:
f:
- {min: 0, max: 4, code: zone_1, label: "Зона I"}
- {min: 5, max: 9, code: zone_2, label: "Зона II"}
- {min: 10, max: 10, code: zone_3, label: "Зона III"}
- {min: 11, max: 40, code: zone_4, label: "Зона IV"}
m:
- {min: 0, max: 8, code: zone_1, label: "Зона I"}
- {min: 9, max: 13, code: zone_2, label: "Зона II"}
- {min: 14, max: 16, code: zone_3, label: "Зона III"}
- {min: 17, max: 40, code: zone_4, label: "Зона IV"}
+41
View File
@@ -0,0 +1,41 @@
id: rus_audit_s
title: RUS-AUDIT-S
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
translation_note: "Транскрипция рис.3 Буновой и соавт., 2022, DOI 10.17116/profmed20222508121; визуально сверена 2026-10-03. Интеграция не утверждена."
notes: "Пункты 3,9,10 полной шкалы. Зоны не задают автоматический переход к полной шкале. Не применять пороги AUDIT-C."
items:
- id: rus_audit_3
text: "Как часто Вы употребляете как минимум 1,5 литра пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Реже одного раза в месяц"}
- {value: 2, label: "Ежемесячно"}
- {value: 3, label: "Еженедельно"}
- {value: 4, label: "Ежедневно или почти ежедневно"}
- id: rus_audit_9
text: |-
Являлось ли Ваше употребление алкогольных напитков причиной травмы* у Вас или у других людей?
*Повреждение организма, вызванное внешним воздействием (ранением, ушибом, ожогом и т.д.).
options: &history
- {value: 0, label: "Никогда"}
- {value: 2, label: "Да, более 12 месяцев назад"}
- {value: 4, label: "Да, в течение последних 12 месяцев"}
- id: rus_audit_10
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчет употребления Вами алкоголя или советовал выпивать меньше?"
options: *history
range: {min: 0, max: 12}
severity:
- {min: 0, max: 12, code: unclassified, label: "Не выбран пол для интерпретации"}
by_sex:
f:
- {min: 0, max: 1, code: zone_1, label: "Зона I"}
- {min: 2, max: 4, code: zone_2, label: "Зона II"}
- {min: 5, max: 5, code: zone_3, label: "Зона III"}
- {min: 6, max: 12, code: zone_4, label: "Зона IV"}
m:
- {min: 0, max: 3, code: zone_1, label: "Зона I"}
- {min: 4, max: 6, code: zone_2, label: "Зона II"}
- {min: 7, max: 7, code: zone_3, label: "Зона III"}
- {min: 8, max: 12, code: zone_4, label: "Зона IV"}
+59
View File
@@ -0,0 +1,59 @@
id: tsq
title: Опросник на скрининг ПТСР (TSQ)
recall_period: прошлая неделя
instruction: Эта анкета связана с вашими личными реакциями на травматическое событие, которое случилось с вами.
Ниже указаны некоторые реакции, которые иногда возникают у людей после травматического события. Пожалуйста, ответьте
«Да», если вы испытывали следующие симптомы по крайней мере дважды на прошлой неделе.
translation_note: КР ПТСР 2023, ID 753, приложение Г1, с.129–130 PDF; копия emdr.ru. Опечатки сохранены. Актуальность
редакции не подтверждена.
options:
- value: 1
label: ДА
- value: 0
label: НЕТ
items:
- id: tsq_1
text: Тяжелые мысли или воспоминания о событии приходили мне в голову против моей воли
buttons_only: true
- id: tsq_2
text: Мне снились тяжелые сны о том, что со мной случилось
buttons_only: true
- id: tsq_3
text: Я вдруг замечал(а), что действую и чувствую себя так, как будто бы ситуация повторяется снова
buttons_only: true
- id: tsq_4
text: Когда что-то напоминает мне об этом событии, я чувствую себя подавленным
buttons_only: true
- id: tsq_5
text: Когда что-то напоминало мне о случившемся, я испытывал(а) неприятные физические ощущения (потливость, сбой
дыхания, тошноту, учащение пульса и др.)
buttons_only: true
- id: tsq_6
text: У меня нарушен сон (трудности засыпания или частые пробуждения)
buttons_only: true
- id: tsq_7
text: Я чувствовал(а) постоянное раздражение и гнев
buttons_only: true
- id: tsq_8
text: Мне было сложно сосредоточится
buttons_only: true
- id: tsq_9
text: Я стал более осведомлён о потенциальных опасностях для себя и других
buttons_only: true
- id: tsq_10
text: Я все время был(а) напряжен(а) и вздрагивал(а), если что-то внезапно пугало меня
buttons_only: true
range:
min: 0
max: 10
severity:
- min: 0
max: 5
code: negative
label: Ниже порога
- min: 6
max: 10
code: positive
label: Требуется уточнение
license_note: "Текст приложения Г1 клинических рекомендаций 2023 года; условия использования и актуальную редакцию подтвердить до включения."
+25
View File
@@ -0,0 +1,25 @@
# Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005). Local times are in settings.tz.
# Read by app/scheduler/demo.py; used only while the questionnaire release (intake_runtime.yaml) is off.
#
# Every event is delivered once (idempotency key in `scheduler_events`) and only while it is fresh: an event
# older than its late window is not sent at all — after a long jump of the demo clock the patient does not
# receive a pile of yesterday's reminders, and an undelivered reminder never turns into a "missed" dose.
enabled: true
tick_seconds: 30
doses:
late_hours: 2 # reminder not delivered if the dose time is older than this
missed_after_hours: 2 # reminded dose without an answer → status "missed" (SPEC §8)
daily_checkin:
time: "20:00"
late_hours: 4
scales:
time: "10:00" # scales due today (config/modules.yaml schedules) are offered from this hour
late_hours: 10
evaluation:
time: "09:00" # daily monitoring pass: silence > N days, missed doses → caring contact
late_hours: 15
+8 -8
View File
@@ -14,9 +14,9 @@ resources:
phone: "112"
note: "если есть угроза жизни прямо сейчас"
helpline:
name: "Телефон доверия"
phone: "8-800-2000-122"
note: "бесплатно, круглосуточно"
name: "Психологическая помощь МЧС России"
phone: "+7 (495) 989-50-50"
note: "круглосуточно; стоимость звонка по тарифу оператора"
police:
name: "Полиция"
phone: "102"
@@ -30,7 +30,7 @@ variants:
себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку
и побудьте рядом с кем-то, кому доверяете.
resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом."
patient.harm_others:
@@ -39,7 +39,7 @@ variants:
Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее.
Если кому-то угрожает опасность прямо сейчас — позвоните 112.
resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз."
patient.domestic_violence:
@@ -48,7 +48,7 @@ variants:
Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы и телефон доверия помогут найти безопасное место и поддержку.
resources: [emergency, police, helpline]
doctor_notified: "Ваш врач уже уведомлён."
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом."
family.default:
@@ -58,7 +58,7 @@ variants:
спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите —
просто будьте рядом и слушайте.
resources: [emergency, helpline]
doctor_notified: "Лечащий врач уже уведомлён."
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6."
family.domestic_violence:
@@ -68,5 +68,5 @@ variants:
Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы помогут с временным убежищем и поддержкой.
resources: [emergency, police, helpline]
doctor_notified: "Лечащий врач уже уведомлён."
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6."
+118
View File
@@ -0,0 +1,118 @@
# Тексты диалогового движка (issue #10): согласие, возраст, меню, кризис, шкалы, отзыв согласия.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; не обещать конфиденциальность
# сверх реальной; не использовать слова «учёт», «контроль», «психиатрический надзор» (проверяется тестом).
# Кризисный скрипт — в crisis.yaml (отправляется дословно, LLM не вызывается).
status: "черновик, утверждает психиатр (#8)"
settings:
# Повторный C-SSRS по сигналу POSSIBLE не чаще, чем раз в N часов (сигнал всё равно пишется в safety_events).
cssrs_cooldown_hours: 24
# Сколько последних safety-событий хранить в dialog_state.data["safety_events"].
max_safety_events: 50
# Флаги шкал (config/scales/*.yaml), при которых сессия шкалы сразу уходит в кризисный путь.
crisis_flags: [phq9_item9_positive, cssrs_high]
# Шкала-скрининг суицидального риска (POSSIBLE и положительный п.9 PHQ-9 → эта шкала).
risk_screen_scale: cssrs_screen
# Брошенный шаг (чек-ин, шкала): если пациент молчит дольше N часов, следующее событие сначала возвращает
# диалог в idle (уже данные ответы сохранены), затем обрабатывается. 0 — выключено. На кризис не действует.
abandon_after_hours: 6
commands:
# Текстовые команды отзыва согласия (сравниваются после приведения к нижнему регистру).
revoke: ["/revoke", "отозвать согласие", "отзываю согласие"]
consent:
version: "v1-draft-2026-10-03"
text: |-
Здравствуйте! Я — цифровой помощник, который сопровождает вас между визитами к врачу: напоминаю о приёме препарата, спрашиваю о самочувствии и передаю важное вашему лечащему врачу.
Прежде чем начать, мне нужно ваше согласие на обработку персональных данных, в том числе сведений о здоровье (152-ФЗ «О персональных данных», ст. 9 и 10).
• Что обрабатывается: ваши ответы о самочувствии, сне, приёме препарата и результаты коротких опросников. Перед автоматической обработкой из сообщений удаляются имена, телефоны и другие личные данные.
• Кто видит данные: только ваш лечащий врач. Данные хранятся в России и не передаются третьим лицам без вашего согласия, кроме случаев, прямо предусмотренных законом.
• Я не ставлю диагнозов и не заменяю врача. Решения о лечении принимает только врач.
• Участие добровольное. Отказ никак не влияет на вашу медицинскую помощь.
• Отозвать согласие можно в любой момент в один шаг: кнопкой «Отозвать согласие» или командой /revoke.
Я не служба экстренной помощи. Если вам угрожает опасность прямо сейчас — звоните 112.
accept: "Согласен(на)"
decline: "Не согласен(на)"
refused: |-
Понимаю, это ваше право. Без согласия я не обрабатываю и не сохраняю ваши сообщения. Ваше лечение у врача продолжается как обычно.
Если передумаете — нажмите кнопку ниже или отправьте /start.
reconsider: "Я передумал(а)"
code_invalid: "Код подключения не найден или уже использован. Пожалуйста, уточните код у вашего врача и отправьте его ещё раз."
age_gate:
question: "Уточните, пожалуйста: вам уже исполнилось 18 лет? Можно нажать кнопку или написать год рождения (например, 1990)."
adult: "Да, мне 18 или больше"
minor: "Нет, мне меньше 18"
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112.
idle:
welcome: |-
Спасибо, всё готово! Я буду на связи каждый день: напомню о препарате и спрошу о самочувствии. Если захотите что-то рассказать — просто напишите или выберите кнопку ниже.
menu_prompt: "Чем могу помочь?"
buttons:
checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве"
kb_question: "Вопрос о лечении"
contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
stub_checkin: "Короткий опрос о самочувствии скоро появится здесь. А пока можно просто написать, как вы себя чувствуете сегодня."
stub_med_question: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — передам вопрос врачу."
contact_doctor_ack: "Я отметил вашу просьбу связаться с врачом — она появится в вашей карточке у врача. Если ситуация срочная, звоните 112."
visit_stub: "Онлайн-запись появится здесь позже. Пока вы можете записаться к своему врачу через регистратуру клиники."
revoked:
confirm: |-
Согласие отозвано. Я больше не буду писать вам и обрабатывать ваши сообщения, а ваши данные помечены к удалению.
Если передумаете — отправьте /start. Если вам угрожает опасность — звоните 112.
crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
scale:
intro: "Несколько коротких вопросов ({count}). {instruction}"
question: "{n}/{total}. {text}"
clarify: "Не совсем понял ответ. Выберите, пожалуйста, вариант кнопкой или опишите, как часто это было."
buttons_only: "На этот вопрос, пожалуйста, ответьте кнопкой."
done: "Спасибо, ответы сохранены. Ваш врач увидит результаты."
risk_screen_intro: "Спасибо, что поделились. Мне важно задать несколько коротких вопросов, чтобы лучше понять, как вы сейчас."
safety_plan:
title: "Ваш план безопасности:"
# Шаблон Стэнли–Брауна, если план ещё не составлен с врачом на визите.
template:
- "Мои тревожные сигналы: что я замечаю, когда становится хуже."
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
visit:
button: "Записаться к врачу раньше"
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
+37
View File
@@ -0,0 +1,37 @@
unavailable: "Этот опрос пока недоступен. Если нужна помощь, свяжитесь с врачом."
done: "Ответы сохранены. Дальнейшие действия можно обсудить с врачом."
review: "Опрос приостановлен. Для продолжения нужна оценка специалиста. Если есть непосредственная угроза жизни — позвоните 112."
paused: "Опрос приостановлен, ответы сохранены."
invalid: "Выберите один из предложенных вариантов ответа."
numeric: "Введите число часов от 0 до 24, например 6,5."
help: "Мне нужна помощь сейчас"
pause: "Продолжить позже"
resume: "Продолжить опрос"
start: "Пройти первичный опрос"
comment_skip: "Не добавлять комментарий"
recent_trauma: "Событие произошло менее месяца назад?"
sex: "Укажите пол для интерпретации алкогольной шкалы."
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?"
numeric_portions: "Введите число стандартных порций, например 0 или 2."
diary_done: "Дневник на сегодня уже заполнен. Спасибо!"
periods:
since_last_contact: "С момента Вашего последнего ответа на эти вопросы."
past_month: "За последний месяц."
lifetime: "За всю жизнь."
past_3_months: "За последние три месяца."
standard_drinks: |-
Стандартная порция (СП) = 10 г этанола. Таблица бланка RUS-AUDIT:
Вино/шампанское · пиво · креплёное вино · крепкий алкоголь
1–2 СП: до 250 мл · до 650 мл · до 170 мл · до 80 мл
3–4 СП: 251–450 мл · 651–1200 мл · 171–300 мл · 81–140 мл
5–6 СП: 451–660 мл · 1201–1750 мл · 301–430 мл · 141–210 мл
7–9 СП: 661–970 мл · 1751–2500 мл · 431–640 мл · 211–300 мл
10+ СП: более 970 мл · более 2500 мл · более 640 мл · более 300 мл
Если напитков несколько, учитывайте суммарное число стандартных порций.
as_of: "Период отсчитывается от даты начала этого опроса: {day}."
alcohol_advice: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план."
+51
View File
@@ -0,0 +1,51 @@
# Вопросы пациента о состоянии и лечении → база знаний по клиническим рекомендациям (#64, RAG #60).
# Зона clinical вычитывает формулировки. Сам ответ — только цитата из КР с источником (config/texts/rag.yaml
# задаёт его обрамление); здесь — тексты диалога и признаки того, что свободное сообщение — вопрос.
# Кнопка меню «Вопрос о лечении» → бот просит написать вопрос.
prompt: |-
Напишите ваш вопрос о состоянии или лечении — например, «что такое флэшбек?» или «помогает ли психотерапия?».
Я отвечу цитатой из клинических рекомендаций Минздрава и укажу источник. Вопросы о дозе и о вашем препарате решает врач.
# Вопрос о дозе (увеличить, уменьшить, полтаблетки): бот не отвечает по существу — это решение врача.
dose_redirect: "Дозу и схему приёма подбирает только ваш врач — менять их самостоятельно нельзя. Могу передать ваш вопрос врачу: нажмите «Связаться с врачом»."
# Свободное сообщение считается вопросом, если в нём есть «?» или оно начинается с одного из этих слов.
# Тогда бот ищет ответ в базе знаний; если ответа нет — отвечает обычной фразой поддержки.
question_starts:
- "что такое"
- "что значит"
- "что означает"
- "почему"
- "зачем"
- "как"
- "когда"
- "сколько"
- "можно ли"
- "нужно ли"
- "надо ли"
- "помогает ли"
- "поможет ли"
- "правда ли"
- "бывает ли"
- "что делать"
- "что будет"
- "чем"
- "где"
- "есть ли"
# Бытовые фразы — не вопросы о лечении, даже со «?»: «как дела?», «как ты?». База знаний для них не ищется
# (иначе «как дела» находит «линия делит» — у слов одна основа), бот отвечает обычной фразой поддержки.
small_talk:
- "как дела"
- "как ты"
- "как вы"
- "как жизнь"
- "как поживаете"
- "что нового"
- "кто ты"
- "ты кто"
- "ты бот"
- "ты робот"
- "привет"
- "спасибо"
+98
View File
@@ -0,0 +1,98 @@
# Тексты приёма препарата (issue #37): напоминание, пропуск, побочные эффекты, вопрос о лекарстве, ответы
# на действия правил med_info (отсроченный эффект, алкоголь, побочные эффекты).
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам и схеме приёма (решает врач);
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом). Ответы о
# препарате берутся только из выдержки инструкции ГРЛС (app/llm/med_qa.py); тексты ниже — обрамление и
# запасные варианты, если выдержки нет.
status: "черновик, утверждает психиатр (#8)"
reminder:
# {drug}, {dose} — из назначения врача (МНН и дозировка как в назначении, без изменений).
text: "Время принять {drug} {dose}. Отметьте, пожалуйста, как получилось."
text_no_drug: "Время принять препарат, назначенный врачом. Отметьте, пожалуйста, как получилось."
taken: "Принял(а)"
skip: "Пропущу"
side_effects: "Есть побочные эффекты"
taken_ack: "Отлично, отметил приём. Спасибо!"
# Кнопка от напоминания, которое уже неактуально (например, вчерашнего).
stale: "Это напоминание уже неактуально. Если хотите отметить самочувствие — нажмите «Как я сегодня»."
skip:
question: "Понял, отметил. Подскажите, что помешало? Это поможет вашему врачу."
reasons:
forgot: "Забыл(а)"
side_effects: "Из-за побочных эффектов"
quit: "Решил(а) бросить"
other: "Другое"
ack:
forgot: "Понял, бывает. Завтра напомню в обычное время."
quit: "Спасибо, что честно сказали — это важно, и решать это лучше вместе с врачом."
other: "Понял, отметил. Если захотите рассказать подробнее — просто напишите."
side_effects:
question: "Что беспокоит? Выберите вариант или «Другое», чтобы описать своими словами."
options:
nausea: "Тошнота"
insomnia: "Бессонница"
headache: "Головная боль"
dizziness: "Головокружение"
agitation: "Беспокойство"
other: "Другое"
# Короткий вопрос к инструкции препарата (med_qa, раздел «побочные эффекты») для выбранного эффекта.
questions:
nausea: "тошнота после приёма, когда пройдёт"
insomnia: "бессонница, нарушения сна"
headache: "головная боль"
dizziness: "головокружение"
agitation: "беспокойство, тревога"
describe: "Опишите, пожалуйста, коротко, что вы заметили."
ack: "Спасибо, отметил. Ваш врач увидит это в вашей карточке."
# Тот же эффект был и вчера: инструкцию второй раз не повторяем.
repeat: "Понимаю, это неприятно. Я отмечаю это каждый день, и ваш врач увидит, если не проходит."
ask_dose: "А препарат сегодня приняли?"
dose_yes: "Да, принял(а)"
dose_no: "Нет"
# Ежедневный чек-ин: если сегодня ещё нет отметки о приёме.
checkin:
question: "Сегодня приняли {drug}?"
question_no_drug: "Сегодня приняли назначенный препарат?"
"yes": "Да"
"no": "Нет"
later: "Ещё нет"
question:
prompt: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — подскажу обратиться к врачу."
no_prescription: "Я не нашёл в карточке назначенного препарата. Пожалуйста, уточните вопрос у вашего врача."
# Ответы на действие правила med_info. Ответ = lead + выдержка из инструкции (med_qa по разделу section,
# вопрос question) + notice. Если выдержки нет — lead + fallback + notice.
med_info:
delayed_effect:
section: onset
question: "когда начнёт действовать препарат, нет эффекта, только хуже"
lead: "Понимаю, что сейчас тяжело."
fallback: "Эффект таких препаратов обычно наступает не сразу, а через 2–4 недели регулярного приёма. Пожалуйста, не прекращайте приём самостоятельно."
notice: "Я сообщил вашему врачу — он свяжется с вами, чтобы обсудить, как вы себя чувствуете."
alcohol_interaction:
section: alcohol
question: "можно ли алкоголь вместе с препаратом, сон"
lead: "Спасибо, что честно рассказали."
fallback: "В инструкции к препарату есть предупреждение о совместном приёме с алкоголем, а алкоголь сам по себе ухудшает сон."
notice: "Я передал это вашему врачу."
nausea:
section: side_effects
question: "тошнота после приёма, когда пройдёт"
lead: ""
fallback: "Тошнота часто встречается в первые дни приёма и обычно проходит сама."
notice: "Если станет хуже — напишите мне, я передам врачу."
side_effects:
section: side_effects
question: "побочные эффекты в начале приёма"
lead: ""
fallback: "Спасибо, что сообщили о самочувствии. Я отмечу это для вашего врача."
notice: "Если станет хуже — напишите мне, я передам врачу."
+2
View File
@@ -21,6 +21,8 @@ phrases:
doctor_notified: "Я передал информацию вашему врачу. Он свяжется с вами."
# med_qa: базовый смысл = выдержка из инструкции ГРЛС; LLM только упрощает её, ничего не добавляя.
med_answer: "{excerpt}"
# RAG (#60): базовый смысл = выдержка из клинических рекомендаций; LLM только упрощает её, ничего не добавляя.
rag_answer: "{excerpt}"
# Шаблон саммари для врача (только по структурированным данным).
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: ""
+120
View File
@@ -0,0 +1,120 @@
# Patient-facing interpretation of questionnaire results (ADR-008: texts in config). The rules that pick a text are
# deterministic (app/core/interpretation.py) and use the thresholds of config/questionnaire*.yaml, PDF «Опросники для
# MVP» (stage 1) and «Этап 2» (follow-up). The app never diagnoses: every result is a screening result.
disclaimer: "Это результат скрининга, а не диагноз. Диагноз ставит врач после очной консультации."
intake:
ok:
title: "Признаков депрессии и ПТСР не выявлено"
text: "По Вашим ответам сейчас нет признаков, которые требуют обращения к специалисту. Можно спать спокойно: продолжайте короткий дневник — так Вы заметите изменения раньше."
attention:
title: "Есть признаки, которые стоит обсудить со специалистом"
text: "Это не диагноз, но ответы похожи на те, что бывают при состояниях ниже. Ниже — что именно мы заметили и что делать дальше."
findings:
depression_suspected:
title: "Предположение депрессии"
text: "Ответы указывают на выраженные симптомы депрессии за последние 2 недели. Включаем модуль «Депрессия» в ежедневном дневнике."
depression_mild:
title: "Лёгкие симптомы депрессии"
text: "Есть отдельные симптомы сниженного настроения. Они часто проходят сами, но за ними стоит последить."
ptsd_suspected:
title: "Склонность к ПТСР"
text: "Ответы указывают на выраженные последствия пережитого травматического события за последний месяц. Включаем модуль «ПТСР» в ежедневном дневнике."
ptsd_signs:
title: "Отдельные признаки посттравматического стресса"
text: "После травматического события есть отдельные реакции, но их выраженность ниже порога вероятного ПТСР."
alcohol_risky:
title: "Рискованное употребление алкоголя"
text: "Употребление алкоголя выше безопасного уровня. Стоит сократить его и обсудить это с врачом."
alcohol_high:
title: "Высокий риск зависимости от алкоголя"
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low:
title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия."
tbi:
title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога."
routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога."
self_help: "Достаточно самопомощи и короткого ежедневного дневника."
modules:
title: "Что дальше"
diary: "Каждый день — короткий дневник: 3 вопроса утром и 2 вечером{modules}. Полные опросники — по расписанию."
names:
depression: "«Депрессия»"
veteran: "«ПТСР»"
with: ", плюс модуль {names}"
scales:
phq9:
title: "Опросник PHQ-9 (депрессия)"
levels:
minimal: "минимальные симптомы"
mild: "лёгкие симптомы"
moderate: "умеренные симптомы"
moderately_severe: "умеренно тяжёлые симптомы"
severe: "тяжёлые симптомы"
pcl5:
title: "Опросник PCL-5 (ПТСР)"
levels:
below: "ниже порога вероятного ПТСР"
probable: "выше порога вероятного ПТСР"
rus_audit_s:
title: "Опросник RUS-AUDIT-S (алкоголь)"
levels:
zone_1: "относительно низкий риск"
zone_2: "опасное употребление"
zone_3: "пагубное употребление"
zone_4: "риск зависимости"
unknown: "нельзя оценить без указания пола"
score: "{title}: {total} — {level}."
change:
remission: "Это ремиссия по шкале — отличный результат."
response: "По сравнению с началом наблюдения — ответ на лечение."
improved: "По сравнению с началом наблюдения — клинически значимое улучшение."
stable: "По сравнению с началом наблюдения существенных изменений нет."
worse: "По сравнению с прошлым замером состояние ухудшилось. Врач получит сигнал и пересмотрит план."
diary:
saved: "Спасибо, дневник за {part} сохранён."
parts:
morning: "утро"
evening: "вечер"
flags:
phq9_due_mood: "Настроение за неделю заметно ниже Вашей обычной нормы — предложим полный опросник PHQ-9 раньше срока."
phq9_due_depression: "Симптомы депрессии за неделю выше Вашей нормы — предложим полный опросник PHQ-9 раньше срока."
pcl5_due_ptsd: "Симптомы ПТСР за неделю выше Вашей нормы — предложим полный опросник PCL-5 раньше срока."
sleep_review: "Несколько ночей подряд очень плохой или короткий сон. Врач увидит это в сводке."
alcohol_on_medication: "Много алкоголя при приёме лекарств опасно. Врач получит отметку."
rus_audit_due_alcohol: "За неделю было несколько дней с большим количеством алкоголя — предложим опросник об алкоголе."
medication_contact: "Несколько дней подряд без лекарств. Не прекращайте приём без врача — мы сообщим ему."
adherence_review: "За неделю лекарства пропущены чаще, чем в 1 из 5 дней. Обсудите это с врачом."
side_effects_review: "Вы отметили побочные эффекты — врач получит отметку."
cssrs:
none: "Спасибо за ответы."
low: "Спасибо, что ответили честно. Откройте план безопасности, если станет тяжело."
kinds:
intake: "Первичный опрос"
diary_morning: "Дневник — утро"
diary_evening: "Дневник — вечер"
cssrs_since_contact: "Вопросы о безопасности"
phq9: "PHQ-9 по расписанию"
pcl5: "PCL-5 по расписанию"
rus_audit_s: "Алкоголь по расписанию"
diary: "Дневник"
status:
complete: "пройден"
review_required: "остановлен для оценки специалиста"
interrupted: "прерван: открыта экстренная помощь"
active: "в процессе"
paused: "на паузе"
cancelled: "отменён"
+62
View File
@@ -0,0 +1,62 @@
# Тексты ежедневного чек-ина и плановых шкал (issue #37, ADR-002, CLINICAL_MODULES §1–2).
# Ядро (настроение + приём) задаётся всем; вопрос про сон и кошмары — модулю veteran; алкоголь — см. settings.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; пациент не видит баллов шкал;
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом).
status: "черновик, утверждает психиатр (#8)"
settings:
# Модули, которым задаётся вопрос про сон и кошмары (modules.yaml: daily_question = sleep_nightmares).
sleep_modules: [veteran]
# Ежедневный вопрос об алкоголе: модули, где он задаётся всегда (у ветеранов алкоголь — частый способ
# «заглушить» бессонницу и фактор риска, CLINICAL_MODULES §1); остальным — при положительном AUDIT-C
# (порог берётся из правила auditc_positive в config/rules.yaml, ADR-002).
alcohol_daily_modules: [veteran]
# Плановые шкалы, перед которыми показывается дружелюбное вступление (см. scale_intros).
intro_scales: [pcl5, phq9, dar5, auditc]
checkin:
start: "Добрый день! Короткая отметка о самочувствии — это займёт меньше минуты."
mood:
question: "Как ваше настроение сегодня по шкале от 0 до 10? 0 — очень плохо, 10 — отлично."
sleep:
question: "Сколько часов удалось поспать прошлой ночью?"
options:
"<4": "Меньше 4 ч"
"4-6": "4–6 ч"
"6-8": "6–8 ч"
">8": "Больше 8 ч"
nightmares:
question: "Снились ли кошмары или тяжёлые сны?"
"yes": "Да"
"no": "Нет"
alcohol:
question: "Выпивали ли вы алкоголь за последние сутки?"
clarify: "Вы упомянули алкоголь. Сколько примерно выпили за последние сутки?"
options:
none: "Не пил(а)"
"1-2": "1–2 порции"
more: "Больше"
text:
question: "Как вы? Можно пару слов — что было за день, что беспокоит."
skip: "Пропустить"
buttons_hint: "Выберите, пожалуйста, вариант кнопкой."
done: "Спасибо, записал. Если что-то изменится — пишите в любое время."
already_done: "Сегодня вы уже отмечались, спасибо! Если хотите что-то добавить — просто напишите."
visit:
# Нажата кнопка «Записаться к врачу раньше» (действие правила suggest_visit). Онлайн-записи пока нет —
# просьба передаётся врачу как факт в карточке.
requested: "Передал врачу просьбу о более раннем визите. С вами свяжутся, чтобы договориться о времени. Если станет хуже до визита — пишите мне или звоните 112."
# Дружелюбное вступление перед шкалой (параметр intro у start_scale). Пациент не видит баллов.
scale_intros:
scheduled:
pcl5: "Раз в две недели я прошу ответить на вопросы о том, как беспокоят тяжёлые воспоминания и их последствия. Это займёт 3–4 минуты. Так ваш врач увидит, что меняется со временем."
phq9: "Пора для короткого опроса о настроении и силах за последние две недели — 9 вопросов. Отвечайте так, как есть, правильных ответов нет."
dar5: "Несколько вопросов о раздражительности и вспышках гнева. После службы это бывает у многих, и врачу важно видеть, как это меняется."
auditc: "Три коротких вопроса об алкоголе. Отвечайте как есть: это нужно врачу, чтобы лечение было безопасным."
unscheduled:
phq9: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас."
+92
View File
@@ -0,0 +1,92 @@
# Тексты стартового онбординга ветерана (issue #36): вступление, вопрос о травматическом опыте, пол для AUDIT-C,
# вопрос о доступе к оружию, блок безопасного хранения, итог, FAQ.
# СТАТУС: черновик, утверждает психиатр (#8). Ответы FAQ — формулировку проверяет юрист.
#
# Правила (проверяются тестом tests/test_dialog_veteran_onboarding.py):
# * тон — как у сослуживца: на «вы», коротко, без жалости и без нравоучений;
# * без слов «диагноз», «расстройство», «психолог», «психиатр», «болезнь», «псих»;
# * о сервисе — никогда «учёт», «контроль», «надзор» (страх пациента можно процитировать только в подписи
# кнопки FAQ — так человек узнаёт свой вопрос);
# * пациент не видит баллов и названий шкал.
status: "черновик, утверждает психиатр (#8)"
settings:
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
# Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию.
weapon_risk_flags: [phq9_item9_positive, cssrs_high, cssrs_moderate, auditc_positive]
intro:
text: |-
Рад, что вы здесь. Пара слов, прежде чем начнём.
После возвращения многие замечают одно и то же: сон сбит, в голову лезет прошлое, тело всё ещё «на дежурстве», раздражает то, что раньше не трогало, тянет выпить, чтобы отпустило. Это частая реакция на то, через что вы прошли, а не слабость — и с этим реально работают.
Сейчас будет несколько коротких вопросов про сон, напряжение, настроение и алкоголь — минут пять, почти всё кнопками. Ответы увидит только ваш лечащий врач: так он сможет помогать точнее.
go: "Начнём"
repeat: "Когда будете готовы — нажмите «Начнём». Если есть вопросы, ответы на частые — кнопками ниже."
resume: "Давайте продолжим с того места, где остановились. Осталось немного."
buttons_hint: "Пожалуйста, выберите вариант кнопкой ниже."
exposure:
question: |-
Бывало ли с вами событие, которое сильно напугало, потрясло или было по-настоящему опасным для жизни — на службе, в бою или в другое время?
Подробности рассказывать не нужно.
answer_yes: "Да"
answer_no: "Нет"
decline: "Не хочу отвечать"
ack_yes: "Понял. Несколько вопросов о том, как это даёт о себе знать сейчас."
ack_other: "Хорошо, идём дальше."
sex:
question: "Один уточняющий вопрос: ваш пол? Он нужен только для того, чтобы правильно учесть ответы про алкоголь — нормы для мужчин и женщин разные."
male: "Мужской"
female: "Женский"
decline: "Не хочу отвечать"
weapon:
question: |-
Последний вопрос, он про безопасность, а не про проверку: есть ли у вас сейчас доступ к оружию — табельному, охотничьему, наградному или любому другому?
no_access: "Нет"
self: "Есть, хранится у меня"
others_or_safe: "Есть, у других / в сейфе"
decline: "Не хочу отвечать"
ack: "Спасибо, что ответили."
# Блок безопасного хранения — фиксированный текст, показывается только при доступе к оружию И сигнале риска.
safety_block_self: |-
Одна просьба — без нравоучений. Пока вам тяжело, пусть оружие и патроны побудут у того, кому вы доверяете: у брата, друга, в сейфе, ключ от которого у другого человека. Дело не в недоверии к вам. В трудную минуту важно, чтобы между мыслью и действием было время — это одна из самых надёжных мер защиты, и так делают многие.
Это можно спокойно обсудить с врачом на приёме.
safety_block_stored: |-
Хорошо, что оружие хранится не под рукой. Пусть пока так и остаётся: ключ или код — у человека, которому вы доверяете, а патроны — отдельно. В трудную минуту важно, чтобы между мыслью и действием было время.
Это можно спокойно обсудить с врачом на приёме.
safety_block_unknown: |-
Понимаю, что не хочется об этом говорить. Просто на всякий случай: если дома есть оружие или большой запас лекарств, пусть пока они побудут у человека, которому вы доверяете. В трудную минуту важно, чтобы между мыслью и действием было время.
result:
intro: "Спасибо, что ответили на всё. Коротко, что видно по ответам:"
calm: "Сейчас серьёзных трудностей по ответам не видно. Хорошо — будем держать руку на пульсе."
load: "Нагрузка сейчас заметная. Так бывает у многих после возвращения, и это то, с чем врач может помочь."
ptsd: "• Прошлое пока не отпускает: напряжение, воспоминания, сон."
mood: "• Настроение и силы ниже, чем хотелось бы."
alcohol: "• Алкоголь — стоит обсудить с врачом, особенно если назначат препараты: они с алкоголем плохо сочетаются."
next: "Ваши ответы уже у лечащего врача — он обсудит их с вами на приёме. А я буду на связи каждый день с одним коротким вопросом и напоминанием о препарате."
faq:
who:
button: "Кто увидит мои ответы?"
# формулировку проверяет юрист
text: |-
Только ваш лечащий врач — тот, кто дал вам код подключения. Перед автоматической обработкой из сообщений удаляются имена, телефоны и другие личные данные. Данные хранятся в России.
Отозвать согласие и остановить обработку можно в любой момент — кнопкой «Отозвать согласие» или командой /revoke.
unit:
button: "Это попадёт на учёт / в часть?"
# формулировку проверяет юрист
text: |-
Нет. Это добровольная консультативная помощь между визитами к врачу. Ваши ответы не передаются в часть, военкомат, работодателю или кому-то ещё без вашего согласия — кроме случаев, прямо предусмотренных законом. Отказ от помощника никак не влияет на ваше лечение.
licences:
button: "Заберут права на оружие или вождение?"
# формулировку проверяет юрист
text: |-
Помощник не выдаёт заключений и не отправляет ваши ответы ни в ГИБДД, ни в Росгвардию, ни на медкомиссии. Допуск к оружию и вождению решает отдельная медицинская комиссия по своим правилам, установленным законом.
Как именно это связано с вашим лечением — спросите лечащего врача прямо. Это нормальный вопрос, его задают многие, и честный ответ лучше, чем откладывать помощь из-за неизвестности.
+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
import streamlit as st
* file name `<N>[letter]_<Title>.py` — N (and the optional letter) sets the order, underscores in the title become
spaces (`2_Демо-консоль.py` → «Демо-консоль»);
* section: pages with N == 1 go to «Кабинет врача», the rest to «Демо»;
* optional module-level constants (read as text, the file is not imported):
`NAV_TITLE = "..."`, `NAV_ICON = ":material/terminal:"`, `NAV_SECTION = "..."`, `NAV_URL = "slug"`.
"""
API = os.environ.get("API_BASE_URL", "http://localhost:8000")
from __future__ import annotations
st.set_page_config(page_title="PTSRaiAgent — кабинет врача", page_icon="🩺", layout="wide")
st.title("Кабинет врача")
st.caption("Прототип на синтетических данных")
import re
import sys
from dataclasses import dataclass
from pathlib import Path
try:
status = httpx.get(f"{API}/health", timeout=2).json().get("status")
except httpx.HTTPError:
status = "недоступен"
st.write(f"API: **{status}**")
ROOT = Path(__file__).resolve().parents[1]
_HERE = str(Path(__file__).resolve().parent)
# `streamlit run` puts dashboard/ on sys.path, where this very file would shadow the backend `app` package
# (needed for the frozen schemas). Import everything from the repo root as `dashboard.*` / `app.*` instead.
sys.path[:] = [p for p in sys.path if p not in ("", _HERE) and Path(p or ".").resolve() != Path(_HERE)]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
PAGES_DIR = Path(__file__).resolve().parent / "pages"
DOCTOR_SECTION = "Кабинет врача"
DEMO_SECTION = "Демо"
DEFAULT_ICONS = {
"Пациенты": ":material/groups:",
"Подключить пациента": ":material/person_add:",
"Демо-консоль": ":material/smartphone:",
"Сплит-экран": ":material/splitscreen:",
}
_NAME_RE = re.compile(r"^(\d+)([a-z]?)_(.+)$")
_META_RE = re.compile(r"""^NAV_(TITLE|ICON|SECTION|URL)\s*(?::\s*str\s*)?=\s*["'](.+?)["']\s*$""", re.MULTILINE)
_TRANSLIT = dict(
zip(
"абвгдеёжзийклмнопрстуфхцчшщъыьэюя",
[*"abvgdee", "zh", *"zijklmnoprstufh", "ts", "ch", "sh", "sch", "_", "y", "_", "e", "yu", "ya"],
strict=True,
)
)
@dataclass(frozen=True)
class PageSpec:
path: Path
title: str
icon: str
section: str
url_path: str
order: tuple[int, str]
def slugify(title: str) -> str:
out = "".join(_TRANSLIT.get(ch, ch) for ch in title.lower())
out = out.replace("_", "")
return re.sub(r"[^a-z0-9]+", "-", out).strip("-") or "page"
def discover_pages(pages_dir: Path = PAGES_DIR) -> list[PageSpec]:
specs = []
for path in pages_dir.glob("*.py"):
m = _NAME_RE.match(path.stem)
if not m:
continue
number, letter, raw = int(m.group(1)), m.group(2), m.group(3)
meta = dict(_META_RE.findall(path.read_text(encoding="utf-8")))
title = meta.get("TITLE", raw.replace("_", " "))
specs.append(
PageSpec(
path=path,
title=title,
icon=meta.get("ICON", DEFAULT_ICONS.get(title, ":material/description:")),
section=meta.get("SECTION", DOCTOR_SECTION if number == 1 else DEMO_SECTION),
url_path=meta.get("URL", slugify(title)),
order=(number, letter),
)
)
return sorted(specs, key=lambda s: s.order)
def main() -> None:
import streamlit as st
from dashboard import theme
from dashboard.components import live_alerts, state, ui
st.set_page_config(page_title="Кабинет врача · MedITron", page_icon="🩺", layout="wide")
st.html(theme.CSS)
st.logo(str(ROOT / "dashboard" / "assets" / "logo.svg"), size="large")
specs = discover_pages()
nav: dict[str, list[st.Page]] = {}
section_of: dict[str, str] = {}
for i, s in enumerate(specs):
page = st.Page(str(s.path), title=s.title, icon=s.icon, url_path=s.url_path, default=i == 0)
nav.setdefault(s.section, []).append(page)
section_of[s.url_path] = s.section
current = st.navigation(nav, position="sidebar", expanded=True)
with st.sidebar:
st.html(
'<div class="mt-sidebar-note"><strong>Рядом между визитами</strong>'
"Наблюдение за состоянием пациента и поддержка назначенного лечения.</div>"
)
st.caption("Прототип на синтетических данных")
short, _ = state.doctor_names()
st.html(ui.workspace_header(short, current.title))
live_alerts.flush_pending_toasts()
# Full-app rerun on a new alert only on doctor pages: demo-console pages may be mid-scenario.
live_alerts.render(rerun_app_on_new=section_of.get(current.url_path) == DOCTOR_SECTION)
current.run()
ui.footer()
if __name__ == "__main__":
main()
+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()

Some files were not shown because too many files have changed in this diff Show More