Compare commits

...
Author SHA1 Message Date
Pavel Eliseev a4ee3d25f0 fix(web): allow returning home from questionnaires
CI / ruff · pytest (pull_request) Successful in 2m28s
Keep the shared dialog and submitted answers while leaving the focus screen. Remove the history trap and wire the text-step home action.

Refs #85
2026-10-04 16:14:37 +03:00
Pavel Eliseev 2a2fdb171e feat(ui): extend minimal doctor design across all screens
CI / ruff · pytest (pull_request) Successful in 2m16s
Refs #85
2026-10-04 16:09:57 +03:00
Soyavo afbfbad19e Merge pull request 'feat(backend): prepare MAX linking, webhook API and durable notifications' (#86) from feat/82-max-backend into dev
CI / ruff · pytest (push) Successful in 2m16s
2026-10-04 16:09:13 +03:00
Pavel Eliseev 461c3d02ea fix(backend): redact retryable MAX storage failures
CI / ruff · pytest (pull_request) Successful in 2m4s
Refs #82
2026-10-04 16:04:38 +03:00
Pavel Eliseev 64655ad934 feat(backend): add durable MAX linking and notification queue
Refs #82
2026-10-04 16:04:38 +03:00
Soyavo db3072d759 Merge pull request '[contract] Add opt-in MAX configuration and lifecycle' (#84) from feat/81-max-contract into dev
CI / ruff · pytest (push) Successful in 2m4s
2026-10-04 16:02:52 +03:00
Pavel Eliseev b3e103954f feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m24s
Refs #81
2026-10-04 15:58:25 +03:00
Eleboba bde6ec6855 Merge pull request 'chore(devops): деплой на сервер + GigaChat для чат-агента + без 500 при ошибках провайдера' (#80) from chore/server-deploy into dev
CI / ruff · pytest (push) Successful in 2m13s
2026-10-04 15:48:56 +03:00
ElebobaandClaude Opus 5.5 ade93d959b feat(bot): GigaChat function-calling provider for the chat agent; never 500 on provider errors
CI / ruff · pytest (pull_request) Successful in 2m15s
OpenRouter returns 403 «Access denied by security policy» from the RF server, and parsing that string error
crashed the request (500 instead of the fallback answer). Error parsing is robust now and any agent exception
degrades to the extractive answer. GigaChat tool provider (GigaChat-2-Pro by default) converts the agent's
OpenAI-style history to function_call / role=function; auto mode prefers GigaChat when credentials are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:46:21 +03:00
ElebobaandClaude Opus 5.5 bc044650ac chore(devops): deploy script tolerates offline fetch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:27:09 +03:00
ElebobaandClaude Opus 5.5 473b884ed8 chore(devops): server deploy next to vobot — compose override, nginx vhost, deploy script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:27:09 +03:00
Arxip222 b94291e541 Merge pull request 'feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату' (#79) from feat/rag-clinical-guidelines into dev
CI / ruff · pytest (push) Successful in 2m2s
2026-10-04 15:25:16 +03:00
Eleboba ebddcae61a Merge pull request 'fix(bot): чат-агент — доработки по живому прогону' (#78) from fix/chat-agent-live into dev
CI / ruff · pytest (push) Successful in 1m59s
2026-10-04 15:22:42 +03:00
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
ElebobaandClaude Opus 5.5 83ad5d8b33 fix(bot): chat agent — ГРЛС excerpts in drug check, questions with alcohol answered, retry, clearer logs
CI / ruff · pytest (pull_request) Successful in 2m13s
Live run through the API (Andrey, real OpenRouter): onset questions now answered from the instruction
excerpt, «можно пиво?» reaches the agent although the check-in records alcohol, quit intent keeps the single
rule reaction, findings are mentioned only for intake/combination questions, one retry on a provider error,
provider error message logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:20:18 +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
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
108 changed files with 14037 additions and 490 deletions
+14 -1
View File
@@ -9,7 +9,7 @@ GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only. # --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto CHAT_AGENT=auto
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))" # --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY= PSEUDO_KEY=
@@ -27,6 +27,9 @@ DOCTOR_NOTIFY_CHAT_ID=
# --- Demo virtual clock (ADR-005) # --- Demo virtual clock (ADR-005)
DEMO_CLOCK=true 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 TZ=Europe/Moscow
# --- Gitea (for Claude Code / team workflow, not used by the app) # --- Gitea (for Claude Code / team workflow, not used by the app)
@@ -34,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER= GITEA_USER=
GITEA_TOKEN= GITEA_TOKEN=
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=
MAX_SITE_URL=
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
MAX_CA_BUNDLE=
MAX_LINK_TTL_SECONDS=600
+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
+6 -6
View File
@@ -1,15 +1,15 @@
# Dashboard-only Streamlit settings (doctor dashboard + demo console). # Dashboard-only Streamlit settings (doctor dashboard + demo console).
[theme] [theme]
base = "light" base = "light"
primaryColor = "#2A78D6" primaryColor = "#285E4E"
backgroundColor = "#F6F7F9" backgroundColor = "#F6F7F3"
secondaryBackgroundColor = "#FFFFFF" secondaryBackgroundColor = "#FFFFFF"
textColor = "#1B1F24" textColor = "#213B38"
borderColor = "#E3E6EA" borderColor = "#E1E7E3"
baseRadius = "medium" baseRadius = "medium"
# Falls back to the system sans-serif when offline (ADR-004). # 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" font = "sans-serif"
headingFontWeights = 700 headingFontWeights = 600
[client] [client]
toolbarMode = "minimal" toolbarMode = "minimal"
+6
View File
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
from app.api.schemas import ( from app.api.schemas import (
AlertOut, AlertOut,
DemoPersona,
DiscontinuationRisk, DiscontinuationRisk,
DoseOut, DoseOut,
PatientCard, PatientCard,
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3) TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova" 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: def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low" level = "high" if score >= 60 else "medium" if score >= 30 else "low"
+115
View File
@@ -0,0 +1,115 @@
"""Server API for a future MAX connect UI; webhook processing is supplied by the channel adapter."""
import hmac
import json
import re
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import get_settings
from app.core import clock
from app.db import repo
from app.db.max_notifications import MaxBinding, MaxDelivery
from app.db.session import get_clinical_session, get_identity_session
from app.integrations import max_store
router = APIRouter(tags=["max-notifications"])
bearer = HTTPBearer(auto_error=False)
async def patient_token(auth: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str:
if auth is None or auth.scheme.lower() != "bearer" or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", auth.credentials):
raise HTTPException(401, "patient authentication required", headers={"WWW-Authenticate": "Bearer"})
async with get_clinical_session() as session:
if await repo.get_profile(session, auth.credentials) is None:
raise HTTPException(401, "patient authentication required")
return auth.credentials
PatientToken = Annotated[str, Depends(patient_token)]
class MaxStatus(BaseModel):
available: bool
connected: bool
last_delivery: str | None = None
class LinkConsent(BaseModel):
model_config = ConfigDict(extra="forbid")
consent: Literal[True]
class MaxLink(BaseModel):
url: str
expires_at: datetime
@router.get("/me/notifications/max", response_model=MaxStatus)
async def status(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
binding = await session.get(MaxBinding, token)
last = await session.scalar(
select(MaxDelivery.state)
.where(MaxDelivery.token == token)
.order_by(MaxDelivery.created_at.desc(), MaxDelivery.id.desc())
.limit(1)
)
return MaxStatus(
available=get_settings().max_enabled, connected=bool(binding and binding.enabled), last_delivery=last
)
@router.post("/me/notifications/max/link", response_model=MaxLink)
async def link(body: LinkConsent, token: PatientToken) -> MaxLink:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
if not await max_store.active_patient(token):
raise HTTPException(403, "active consent and adult confirmation required")
async with get_identity_session() as session:
raw, expires = await max_store.issue_code(session, token, clock.now(), settings.max_link_ttl_seconds)
return MaxLink(url=f"https://max.ru/{settings.max_bot_username}?start={raw}", expires_at=expires)
@router.delete("/me/notifications/max", response_model=MaxStatus)
async def unlink(token: PatientToken) -> MaxStatus:
async with get_identity_session() as session:
await max_store.disconnect(session, token)
return MaxStatus(available=get_settings().max_enabled, connected=False)
@router.post("/webhooks/max")
async def webhook(request: Request) -> dict[str, bool]:
settings = get_settings()
if not settings.max_enabled:
raise HTTPException(503, "MAX is not configured")
expected = settings.max_webhook_secret.get_secret_value().encode()
supplied = request.headers.get("X-Max-Bot-Api-Secret", "").encode()
if not expected or not hmac.compare_digest(supplied, expected):
raise HTTPException(403, "invalid webhook secret")
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > 65536:
raise HTTPException(413, "event is too large")
try:
event = json.loads(raw)
except (ValueError, UnicodeError) as exc:
raise HTTPException(400, "invalid JSON") from exc
if not isinstance(event, dict):
raise HTTPException(422, "event must be an object")
from app.channels.max_bot import handle_update
try:
await handle_update(event)
except SQLAlchemyError:
# Let MAX retry; do not log DB exception parameters containing identity data.
raise HTTPException(503, "notification storage unavailable") from None
return {"success": True}
+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"}
+4 -1
View File
@@ -15,7 +15,8 @@ from sqlalchemy import select
from app.api import demo as console from app.api import demo as console
from app.api import doctor_views as views from app.api import doctor_views as views
from app.api import fixtures from app.api import fixtures, max_notifications
from app.api.patient_web import router as patient_web_router
from app.api.questionnaire import router as questionnaire_router from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
AckIn, AckIn,
@@ -45,7 +46,9 @@ from app.scheduler import jobs as scheduler
from app.services.monitoring import refresh_status from app.services.monitoring import refresh_status
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(max_notifications.router)
router.include_router(questionnaire_router) router.include_router(questionnaire_router)
router.include_router(patient_web_router)
DEFAULT_CLINIC = "clinic-demo" DEFAULT_CLINIC = "clinic-demo"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
+35 -2
View File
@@ -1,10 +1,12 @@
"""Typed application settings. The only place that reads environment variables.""" """Typed application settings. The only place that reads environment variables."""
import re
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -33,8 +35,9 @@ class Settings(BaseSettings):
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII). # data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto" chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = "" openrouter_api_key: str = ""
openrouter_model: str = "anthropic/claude-sonnet-4.5" openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1" openrouter_base_url: str = "https://openrouter.ai/api/v1"
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
chat_agent_timeout_s: float = 25.0 chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5 chat_agent_max_steps: int = 5
@@ -42,6 +45,36 @@ class Settings(BaseSettings):
tg_bot_token: str = "" tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None doctor_notify_chat_id: int | None = None
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
max_enabled: bool = False
max_bot_token: SecretStr = SecretStr("")
max_bot_username: str = ""
max_webhook_secret: SecretStr = SecretStr("")
max_webhook_url: str = ""
max_site_url: str = ""
max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_max(self) -> "Settings":
if not self.max_enabled:
return self
if not self.max_bot_token.get_secret_value().strip():
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
url = urlsplit(value)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
return self
# Virtual clock for the demo (ADR-005). # Virtual clock for the demo (ADR-005).
demo_clock: bool = True demo_clock: bool = True
tz: str = "Europe/Moscow" tz: str = "Europe/Moscow"
+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())
+90 -41
View File
@@ -18,6 +18,7 @@ class Question:
period: str = "context" period: str = "context"
numeric: bool = False numeric: bool = False
optional: bool = False optional: bool = False
maximum: float = 24
def content() -> dict: def content() -> dict:
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None: def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
c = score_cssrs_recent(cssrs_answers(answers)) """Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
if c.missing: then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
form = load_cssrs_russian() Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
item = next(i for i in form.items if i.id == c.missing[0]) so a positive answer interrupts the questionnaire wherever it comes."""
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,
)
for item in ("phq9_1", "phq9_2"): for item in ("phq9_1", "phq9_2"):
if item not in answers: if item not in answers:
return scale_question("phq9", item) return scale_question("phq9", item)
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
for item in load_scale("rus_audit_s").item_ids: for item in load_scale("rus_audit_s").item_ids:
if item not in answers: if item not in answers:
return scale_question("rus_audit_s", item) 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: if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать"))) return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"): for key in ("tbi", "psychotropics"):
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
return None return None
def diary_questions( def cssrs_since_contact_question(answers: dict) -> Question | None:
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None """Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
) -> tuple[Question, ...]:
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text()) The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
selected = ["core"] items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
detailed = [name for name in ("veteran", "depression") if name in modules] is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
if len(detailed) == 2: """
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]] c = score_cssrs_recent(cssrs_answers(answers))
if "veteran" in detailed: missing = [k for k in c.missing if k != "q6_recent"]
selected.append("ptsd") if not missing:
if "depression" in detailed: return None
selected.append("depression") form = load_cssrs_russian()
if suicide_monitoring or "depression" in modules: item = next(i for i in form.items if i.id == missing[0])
selected.append("suicide") return Question(
questions = [] "cssrs_" + item.id,
for section in selected: texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
for item in config[section]: tuple((o.value, o.label) for o in form.options),
if item.get("weekly") and weekday != 0: "since_last_contact",
continue )
questions.append(
Question(
item["id"], DIARY_PARTS = ("morning", "evening")
item["text"], DIARY_KINDS = ("diary_morning", "diary_evening")
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_week" if item.get("weekly") else "past_day",
item.get("numeric", False), def diary_config() -> dict:
item.get("optional", False), 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")
) )
)
questions.sort(key=lambda q: q.id != "death_thoughts")
return tuple(questions) return tuple(questions)
+82 -30
View File
@@ -10,7 +10,7 @@ import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings from app.config import get_settings
from app.core.questionnaire import Approval, load_policy from app.core.questionnaire import Approval, demo_mode, load_policy
class RuntimePolicy(BaseModel): class RuntimePolicy(BaseModel):
@@ -21,13 +21,20 @@ class RuntimePolicy(BaseModel):
content_review: Approval | None = None content_review: Approval | None = None
contact_review: Approval | None = None contact_review: Approval | None = None
timezone: str timezone: str
daily_hour: int = Field(ge=0, le=23) morning_hour: int = Field(ge=0, le=23)
mood_baseline_days: int = Field(ge=2) evening_hour: int = Field(ge=0, le=23)
mood_bad_days: int = Field(ge=1) norm_days: int = Field(ge=2)
mood_drop: int = Field(ge=1, le=10) 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_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24) sleep_hours_below: float = Field(gt=0, le=24)
nightmares_at: int = Field(ge=0, le=4) alcohol_heavy_portions: float = Field(gt=0)
alcohol_heavy_days: int = Field(ge=1)
adherence_below: float = Field(gt=0, le=1)
missed_medication_days: int = Field(ge=1) missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1) high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...] medication_days: tuple[int, ...]
@@ -44,6 +51,8 @@ class RuntimePolicy(BaseModel):
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values() set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
): ):
raise ValueError("all route and scale intervals must be defined") 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: 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") 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()): if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
@@ -64,8 +73,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy:
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text())) 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: def intake_enabled() -> bool:
return load_runtime().released and load_policy().approval is not None return (load_runtime().released and load_policy().approval is not None) or demo_intake()
def month_before(day: date, months: int = 1) -> date: def month_before(day: date, months: int = 1) -> date:
@@ -115,39 +133,73 @@ def due_scales(
return tuple(sorted(due)) return tuple(sorted(due))
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]: def _mean(values: list[float]) -> float:
"""Consecutive calendar dates; missing data never become zero or missed doses. return sum(values) / len(values)
Baseline is the seven dates BEFORE the three-day deterioration window;
every baseline value is required. Nightmares OR short sleep must hold on def diary_flags(
each of three nights. These candidate definitions require signed review. 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() p = policy or load_runtime()
flags = [] flags: list[str] = []
current = history.get(today, {}) current = history.get(today, {})
if current.get("death_thoughts") == 1: start = min(history) if history else today
flags.append("suicide_assessment_now") norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)] week_start = today - timedelta(days=p.week_days - 1)
baseline = [ week = [history[d] for d in sorted(history) if week_start <= d <= today]
history.get(today - timedelta(days=i), {}).get("mood") trend = (week_start - start).days >= p.norm_days
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
] def values(rows: list[dict], key) -> list[float]:
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent): return [v for r in rows if (v := key(r)) is not None]
mean = sum(baseline) / len(baseline)
if all(mean - x["mood"] >= p.mood_drop for x in recent): def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
flags.append("phq9_due_mood") 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)] nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all( if all(
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at) (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
for r in nights for r in nights
): ):
flags.append("sleep_review") 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)] doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "none" for r in doses): if all(r.get("medication") == "no" for r in doses):
flags.append("medication_contact") flags.append("medication_contact")
if current.get("side_effects") == 1: taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
flags.append("adherence_review")
if current.get("medication_reason") == "side_effects":
flags.append("side_effects_review") flags.append("side_effects_review")
if current.get("medication") == "partial":
flags.append("partial_medication_review")
return tuple(flags) 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)
+11 -1
View File
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -69,8 +70,17 @@ class IntakePolicy(BaseModel):
return self 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: def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / "questionnaire.yaml" path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+1
View File
@@ -5,6 +5,7 @@ The only module (besides registration code) that touches both schemas — and on
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata) from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401 from app.db import intake as _intake # noqa: F401
from app.db import max_notifications as _max_notifications # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401 from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase from app.db.identity import IdentityBase
+50
View File
@@ -0,0 +1,50 @@
"""MAX identifiers and delivery metadata live only in the identity DB; no medical message bodies."""
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.identity import IdentityBase
from app.db.types import TZDateTime
class MaxBinding(IdentityBase):
__tablename__ = "max_bindings"
# No FK: anonymous web profiles intentionally have no patients row in identity.
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, unique=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
connected_at: Mapped[datetime] = mapped_column(TZDateTime)
class MaxLinkCode(IdentityBase):
__tablename__ = "max_link_codes"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
used_at: Mapped[datetime | None] = mapped_column(TZDateTime)
class MaxReceipt(IdentityBase):
__tablename__ = "max_webhook_receipts"
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
received_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
class MaxDelivery(IdentityBase):
__tablename__ = "max_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
token: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(24))
state: Mapped[str] = mapped_column(String(16), default="queued", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(TZDateTime)
next_attempt_at: Mapped[datetime] = mapped_column(TZDateTime, index=True)
lease: Mapped[str | None] = mapped_column(String(32))
error_code: Mapped[str | None] = mapped_column(String(32))
+3 -1
View File
@@ -70,7 +70,9 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
modules = list(profile.modules) if profile is not None and profile.modules else [] modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")): # Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
# person has no profile at all before it.
if after is None and intake_enabled():
return "intake.start" return "intake.start"
if after in modules: if after in modules:
modules = modules[modules.index(after) + 1 :] modules = modules[modules.index(after) + 1 :]
+131 -57
View File
@@ -4,9 +4,13 @@ import math
from dataclasses import asdict, replace from dataclasses import asdict, replace
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from app.core import interpretation
from app.core.intake_questions import ( from app.core.intake_questions import (
DIARY_KINDS,
Question,
content, content,
cssrs_answers, cssrs_answers,
cssrs_since_contact_question,
diary_questions, diary_questions,
group_answers, group_answers,
next_intake, next_intake,
@@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale from app.core.scales import load_scale
from app.core.types import AlertLevel from app.core.types import AlertLevel
from app.db import repo
from app.db.intake import QuestionnaireRecord, QuestionnaireTask from app.db.intake import QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Button, Event, Reply from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis from app.dialog.crisis import crisis_open, open_crisis
@@ -28,21 +33,23 @@ def allowed(ctx):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled() 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): def next_question(row):
a = row.answers a = row.answers
if row.kind == "intake": if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex")) return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "diary": if row.kind == "cssrs_since_contact":
for q in diary_questions( return cssrs_since_contact_question(a)
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday() 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: if q.id in a:
continue continue
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"): if q.id == "medication_reason" and a.get("medication") != "no":
continue
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
continue
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
continue continue
return q return q
return None return None
@@ -63,11 +70,7 @@ def next_question(row):
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week") q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q return q
if row.details.get("medication_active") and "side_effects" not in a: if row.details.get("medication_active") and "side_effects" not in a:
return next( return side_effects_question()
q
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
if q.id == "side_effects"
)
return None return None
@@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
if profile: if profile:
profile.enhanced_monitoring = True profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else [] flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0): if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required") flags.append("cssrs_since_contact_required")
if row: if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags}) await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
@@ -132,7 +135,8 @@ async def advance(ctx, row):
a = row.answers a = row.answers
c = score_cssrs_recent(cssrs_answers(a)) c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy() policy = load_policy()
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high": # PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal") return await urgent(ctx, row, code="suicide_signal")
if c.positive and policy.cssrs_positive_policy == "stop_any": if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive") return await urgent(ctx, row, code="cssrs_positive")
@@ -153,9 +157,6 @@ async def advance(ctx, row):
ctx.now, ctx.now,
params={"record_id": row.id}, params={"record_id": row.id},
) )
if row.kind == "cssrs_since_contact":
# Recent and Since Last Contact are different instruments. No silent substitution.
return await manual_review(ctx, row, "cssrs_since_contact_required")
try: try:
q = next_question(row) q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1. # Validate preserved short answers against a newly answered full-form item 1.
@@ -187,6 +188,8 @@ async def advance(ctx, row):
extra = {"decision": asdict(result), "totals": service.summary(row)} extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1: if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True 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"): if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert( await service.alert(
ctx.session, ctx.session,
@@ -196,23 +199,29 @@ async def advance(ctx, row):
ctx.now, ctx.now,
params={"record_id": row.id, **extra}, params={"record_id": row.id, **extra},
) )
elif row.kind == "diary": elif row.kind == "cssrs_since_contact":
history = { extra["totals"] = {"cssrs_recent": asdict(c)}
r.day: r.answers extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
for r in await service.records(ctx.session, ctx.token) elif row.kind in DIARY_KINDS:
if r.kind == "diary" and r.status == "complete" # Morning and evening answers of one date form one diary day.
} history: dict = {}
history[row.day] = a for r in await service.records(ctx.session, ctx.token):
flags = diary_flags(history, row.day) 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 extra["flags"] = flags
for flag in flags: for flag in flags:
await service.alert( await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id} ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
) )
if flag == "phq9_due_mood": if flag in DIARY_SCALE_TASKS:
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now) 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: else:
extra["totals"] = service.summary(row) extra["totals"] = service.summary(row)
runtime = load_runtime()
if row.kind in ("phq9", "pcl5"): if row.kind in ("phq9", "pcl5"):
sid = row.kind sid = row.kind
total = extra["totals"][sid]["total"] total = extra["totals"][sid]["total"]
@@ -223,27 +232,37 @@ async def advance(ctx, row):
if total >= priority if total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician") else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
) )
prior = next( earlier = [
( r
r for r in await service.records(ctx.session, ctx.token)
for r in reversed(await service.records(ctx.session, ctx.token)) if r.id != row.id
if r.id != row.id and r.status == "complete"
and r.status == "complete" and r.version == row.version
and r.version == row.version and r.details.get("totals", {}).get(sid, {}).get("complete")
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))
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False)) ]
), prior = earlier[-1] if earlier else None
None,
)
if prior: if prior:
change = total - prior.details["totals"][sid]["total"] change = total - prior.details["totals"][sid]["total"]
extra["delta"] = { extra["delta"] = {
"baseline_record": prior.id, "baseline_record": prior.id,
"value": change, "value": change,
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"), "retriage": change >= getattr(runtime, sid + "_retriage_delta"),
} }
extra["interpretation"] = interpretation.scale_interpretation(
sid,
extra["totals"],
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
previous=prior.details["totals"][sid]["total"] if prior else None,
policy=policy,
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
)
if row.kind == "rus_audit_s" and not row.details.get("sex"): if row.kind == "rus_audit_s" and not row.details.get("sex"):
return await manual_review(ctx, row, "alcohol_sex_unknown") 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: if extra["totals"]["rus_audit"].get("zone") == 4:
extra["parallel_routes"] = ["narcologist"] extra["parallel_routes"] = ["narcologist"]
retriage = extra.get("delta", {}).get("retriage", False) retriage = extra.get("delta", {}).get("retriage", False)
@@ -257,13 +276,48 @@ async def advance(ctx, row):
params={"record_id": row.id, **extra}, params={"record_id": row.id, **extra},
) )
await service.finish(ctx, row, "complete", extra=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 from app.dialog.flows.core import menu_reply
replies = [menu_reply(ctx, texts()["done"])] return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
if alcohol_total.get("zone") in (2, 3):
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info")) # Diary flags that bring a full scale forward («Этап 2», p.4).
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None}) 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): async def start(ctx, kind="intake", task=None):
@@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None):
if task: if task:
return Step() return Step()
return Step(replies=[Reply(text=texts()["review"], kind="info")]) 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) row = await service.create_record(ctx, kind, task=task)
return await advance(ctx, row) return await advance(ctx, row)
@@ -316,14 +372,28 @@ async def checkin(ctx, event):
existing = await service.records(ctx.session, ctx.token) existing = await service.records(ctx.session, ctx.token)
if not any(r.kind == "intake" and r.status == "complete" for r in existing): if not any(r.kind == "intake" and r.status == "complete" for r in existing):
return await start(ctx) return await start(ctx)
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date() return await next_diary(ctx)
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
return Step(replies=[Reply(text=texts()["done"], kind="info")])
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now) async def next_diary(ctx):
if risk_task.status in ("pending", "active"): """The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now) «since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
return await start(ctx, "cssrs_since_contact", risk_task) day = local_day(ctx)
return await start(ctx, "diary") 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") @state("intake")
@@ -365,12 +435,16 @@ async def collect(ctx, event: Event):
elif event.type == "text" and q.numeric: elif event.type == "text" and q.numeric:
try: try:
value = float(str(ctx.text).replace(",", ".")) value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= 24 valid = math.isfinite(value) and 0 <= value <= q.maximum
except ValueError: except ValueError:
pass pass
elif event.type == "text" and q.optional: elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True value, valid = str(ctx.text)[:2000], True
if not valid: if not valid:
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)]) hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now) 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) return await advance(ctx, row)
+9 -3
View File
@@ -47,6 +47,8 @@ from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
QUESTION_STATE = "meds.question" QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text" SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
# 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") SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other") SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
@@ -379,7 +381,8 @@ def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step: async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other": if code == "other":
return Step( return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)], # 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, next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref}, data={"med_se_ref": ref},
) )
@@ -394,12 +397,15 @@ for _code in SIDE_EFFECT_CODES:
@state(SIDE_EFFECT_TEXT_STATE) @state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step: 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: if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event, {"med_se_ref": None}) return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text) facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log). # 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"] codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
ref = ctx.data.get("med_se_ref")
return await _after_side_effects(ctx, codes, str(ref) if ref else None) return await _after_side_effects(ctx, codes, str(ref) if ref else None)
@@ -429,7 +435,7 @@ async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question t = load_meds_texts().question
if await current_prescription(ctx) is None: if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)]) return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE) return Step(replies=[Reply(text=t.prompt, kind="question")], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str: async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
+3 -1
View File
@@ -448,7 +448,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
await save_checkin(ctx, **fields) await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent: if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx)) text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text): # A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
# Quit intent gets the rule reaction (delayed-onset explanation + doctor notified) — no second answer.
if text is None and not facts.quit_intent and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64) text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None: if text is None:
text = await ctx.gateway.phrase("support") text = await ctx.gateway.phrase("support")
+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)
+201
View File
@@ -0,0 +1,201 @@
"""Registration boundary and durable MAX queue. Callers own the identity transaction.
Only this boundary joins a pseudonymous profile to a MAX recipient. Clinical modules never import it.
Queue rows contain a template name, never replies, scores, prescriptions or patient-auth tokens in URLs.
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.clinical import ScaleResult
from app.db.intake import QuestionnaireRecord
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.db.session import get_clinical_session
MAX_ATTEMPTS = 6
LEASE_SECONDS = 60
def digest(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
async def active_patient(token: str) -> bool:
"""Same consent/adult gate used by the scheduler, checked again at delivery time."""
async with get_clinical_session() as session:
state = await repo.get_dialog_state(session, token)
return bool(
await repo.get_profile(session, token)
and state
and state.data.get("consent") is True
and state.data.get("adult") is True
)
async def issue_code(session: AsyncSession, token: str, now: datetime, ttl: int) -> tuple[str, datetime]:
raw = secrets.token_urlsafe(32)
expires = now + timedelta(seconds=ttl)
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
session.add(MaxLinkCode(digest=digest(raw), token=token, expires_at=expires))
await session.flush()
return raw, expires
async def code_token(session: AsyncSession, raw: str, now: datetime) -> str | None:
row = await session.get(MaxLinkCode, digest(raw))
return row.token if row and row.used_at is None and row.expires_at > now else None
async def bind(session: AsyncSession, raw: str, user_id: int, now: datetime) -> str | None:
"""Atomically consume a code. Existing active identities cannot be silently reassigned."""
token = await code_token(session, raw, now)
if token is None:
return None
owner = await session.scalar(select(MaxBinding).where(MaxBinding.user_id == user_id))
binding = await session.get(MaxBinding, token)
if owner and owner.token != token and owner.enabled:
return None
if binding and binding.user_id != user_id and binding.enabled:
return None
used = await session.execute(
update(MaxLinkCode)
.where(MaxLinkCode.digest == digest(raw), MaxLinkCode.used_at.is_(None), MaxLinkCode.expires_at > now)
.values(used_at=now)
.returning(MaxLinkCode.token)
)
if used.scalar_one_or_none() is None:
return None
if owner and owner.token != token:
await session.delete(owner)
await session.flush()
if binding is None:
binding = MaxBinding(token=token, user_id=user_id, connected_at=now)
session.add(binding)
# Cancel stale deliveries even when the same account reconnects later.
await cancel_pending(session, token)
binding.user_id, binding.enabled, binding.connected_at = user_id, True, now
await session.flush()
return token
async def cancel_pending(session: AsyncSession, token: str) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.token == token, MaxDelivery.state.in_(("queued", "sending")))
.values(state="cancelled", lease=None)
)
async def disconnect(session: AsyncSession, token: str) -> None:
binding = await session.get(MaxBinding, token)
if binding:
binding.enabled = False
await session.execute(delete(MaxLinkCode).where(MaxLinkCode.token == token))
await cancel_pending(session, token)
async def enqueue(
session: AsyncSession, *, key: str, user_id: int, kind: str, now: datetime, token: str | None = None
) -> None:
if await session.get(MaxDelivery, key) is not None:
return
try:
async with session.begin_nested():
session.add(
MaxDelivery(id=key, user_id=user_id, token=token, kind=kind, created_at=now, next_attempt_at=now)
)
await session.flush()
except IntegrityError:
# Another worker may enqueue the same event between SELECT and INSERT.
if await session.get(MaxDelivery, key) is None:
raise
async def collect_completed(session: AsyncSession, now: datetime) -> None:
"""Recover completion events from committed clinical records, including after a restart.
No clinical hooks depend on identity. Read only IDs/dates, then enqueue a generic template.
A binding's activation time bounds history; repeated scans are idempotent.
"""
bindings = (await session.scalars(select(MaxBinding).where(MaxBinding.enabled.is_(True)))).all()
for binding in bindings:
if not await active_patient(binding.token):
continue
async with get_clinical_session() as clinical:
forms = (
await clinical.scalars(
select(QuestionnaireRecord.id).where(
QuestionnaireRecord.token == binding.token,
QuestionnaireRecord.status == "complete",
QuestionnaireRecord.completed_at >= binding.connected_at,
)
)
).all()
scales = (
await clinical.scalars(
select(ScaleResult.id).where(
ScaleResult.token == binding.token,
ScaleResult.complete.is_(True),
ScaleResult.ts >= binding.connected_at,
)
)
).all()
for source, ids in (("form", forms), ("scale", scales)):
for record_id in ids:
key = digest(f"completed:{binding.token}:{binding.connected_at.isoformat()}:{source}:{record_id}")
await enqueue(session, key=key, token=binding.token, user_id=binding.user_id, kind="completed", now=now)
async def claim(session: AsyncSession, now: datetime) -> MaxDelivery | None:
"""One lease per row; recovery after a worker crash is bounded by the lease deadline."""
ready = (MaxDelivery.state.in_(("queued", "sending"))) & (MaxDelivery.next_attempt_at <= now)
row = await session.scalar(
select(MaxDelivery)
.where(ready)
.order_by(MaxDelivery.created_at, MaxDelivery.id)
.with_for_update(skip_locked=True)
.limit(1)
)
if row is None:
return None
if row.attempts >= MAX_ATTEMPTS:
row.state, row.error_code, row.lease = "failed", "attempt_limit", None
return None
lease = secrets.token_hex(16)
result = await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, ready)
.values(
state="sending",
attempts=MaxDelivery.attempts + 1,
lease=lease,
next_attempt_at=now + timedelta(seconds=LEASE_SECONDS),
)
.returning(MaxDelivery.id)
)
if result.scalar_one_or_none() is None:
return None
await session.refresh(row)
return row
async def finish(
session: AsyncSession,
row: MaxDelivery,
*,
state: str,
now: datetime,
error: str | None = None,
retry_seconds: float = 0,
) -> None:
await session.execute(
update(MaxDelivery)
.where(MaxDelivery.id == row.id, MaxDelivery.lease == row.lease, MaxDelivery.state == "sending")
.values(state=state, lease=None, error_code=error, next_attempt_at=now + timedelta(seconds=retry_seconds))
)
+38 -7
View File
@@ -22,6 +22,7 @@ from app.config import Settings, get_settings
from app.core.types import ScrubbedText from app.core.types import ScrubbedText
from app.llm.agent.prompts import SYSTEM_PROMPT from app.llm.agent.prompts import SYSTEM_PROMPT
from app.llm.agent.providers import ( from app.llm.agent.providers import (
GigaChatToolProvider,
MockAgentProvider, MockAgentProvider,
OpenRouterProvider, OpenRouterProvider,
ProviderError, ProviderError,
@@ -34,6 +35,7 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200 MAX_ANSWER_CHARS = 1200
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"} _ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
@@ -48,7 +50,7 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
s = settings or get_settings() s = settings or get_settings()
mode = s.chat_agent mode = s.chat_agent
if mode == "auto": if mode == "auto":
mode = "openrouter" if s.openrouter_api_key else "off" mode = "gigachat" if s.gigachat_credentials else "openrouter" if s.openrouter_api_key else "off"
if mode == "openrouter": if mode == "openrouter":
if not s.openrouter_api_key: if not s.openrouter_api_key:
log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled") log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled")
@@ -57,12 +59,32 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
if mode == "mock": if mode == "mock":
return MockAgentProvider() return MockAgentProvider()
if mode == "gigachat": if mode == "gigachat":
# TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call). if not s.gigachat_credentials:
log.warning("chat agent: gigachat provider not implemented yet — disabled") log.warning("chat agent: gigachat selected without GIGACHAT_CREDENTIALS — disabled")
return None return None
return GigaChatToolProvider(s)
return None return None
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
try:
return await provider.chat(wire, TOOL_SPECS)
except ProviderError as exc:
log.warning("chat agent provider=%s retry after %s", provider.name, exc)
return await provider.chat(wire, TOOL_SPECS)
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]]: def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending.""" """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] return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
@@ -82,14 +104,20 @@ async def answer_chat(
return None return None
ctx = ToolContext(token=token) ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
{"role": "user", "content": str(question)}, {"role": "user", "content": str(question)},
] ]
nudged = False
try: try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2): async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps): for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS) turn = await _chat_with_retry(provider, messages)
if not turn.tool_calls: 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) return _finish(turn.content or "", ctx, provider.name)
messages.append( messages.append(
{ {
@@ -111,7 +139,10 @@ async def answer_chat(
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used)) log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None return None
except (ProviderError, TimeoutError) as exc: except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout") log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
return None
except Exception: # noqa: BLE001 — the agent is optional: any bug must degrade to the extractive answer, not a 500
log.exception("chat agent provider=%s crashed", provider.name)
return None return None
+12 -3
View File
@@ -6,11 +6,20 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
Как работать: Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда 1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines. о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Вопросы «когда подействует», «что будет, если бросить», «как принимать», побочные эффекты — смотри поле
instruction в check_drug_for_patient (инструкция ГРЛС) и дополняй search_guidelines.
Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в 2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом 3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
до приёма. Пустой список не значит «безопасно». словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Находки (findings) упоминай, когда вопрос о приёме, сочетании или безопасности препарата; на вопросы о
сроках действия, отмене, побочных эффектах отвечай по существу, без посторонних находок.
Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», 4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано». «в вашей карте указано».
+93 -1
View File
@@ -52,7 +52,10 @@ class OpenRouterProvider:
self._client = client self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
body = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1, "max_tokens": 900} 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)"} headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
try: try:
if self._client is not None: if self._client is not None:
@@ -63,6 +66,13 @@ class OpenRouterProvider:
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200: if resp.status_code != 200:
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
try:
err = resp.json().get("error", "")
detail = str(err.get("message", "") if isinstance(err, dict) else err)[:200]
except (ValueError, AttributeError):
detail = resp.text[:200]
log.error("openrouter http %s: %s", resp.status_code, detail)
raise ProviderError(f"http {resp.status_code}") raise ProviderError(f"http {resp.status_code}")
try: try:
msg = resp.json()["choices"][0]["message"] msg = resp.json()["choices"][0]["message"]
@@ -81,6 +91,88 @@ class OpenRouterProvider:
return AssistantTurn(content=msg.get("content"), tool_calls=calls) return AssistantTurn(content=msg.get("content"), tool_calls=calls)
class GigaChatToolProvider:
"""GigaChat function calling (RF provider, 152-FZ compliant). Converts the OpenAI-style history the agent keeps:
assistant `tool_calls` → `function_call`, `tool` results → role `function` with the function name.
GigaChat returns at most one function call per turn — the agent loop handles that naturally."""
name = "gigachat"
def __init__(self, settings: Settings, client: Any | None = None) -> None:
self._settings = settings
self._client = client
def _get_client(self) -> Any:
if self._client is None:
from gigachat import GigaChat
s = self._settings
self._client = GigaChat(
credentials=s.gigachat_credentials,
scope=s.gigachat_scope,
model=s.gigachat_agent_model,
verify_ssl_certs=s.gigachat_verify_ssl,
timeout=s.chat_agent_timeout_s,
)
return self._client
@staticmethod
def to_messages(messages: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import FunctionCall, Messages, MessagesRole
out = []
for m in messages:
role = m["role"]
if role == "system":
out.append(Messages(role=MessagesRole.SYSTEM, content=m["content"]))
elif role == "user":
out.append(Messages(role=MessagesRole.USER, content=m["content"]))
elif role == "assistant":
calls = m.get("tool_calls") or []
for c in calls or [None]:
fc = None
if c is not None:
fc = FunctionCall(name=c["function"]["name"], arguments=json.loads(c["function"]["arguments"]))
out.append(Messages(role=MessagesRole.ASSISTANT, content=m.get("content") or "", function_call=fc))
elif role == "tool":
out.append(Messages(role=MessagesRole.FUNCTION, name=m.get("name"), content=m["content"]))
return out
@staticmethod
def to_functions(tools: list[dict[str, Any]]) -> list[Any]:
from gigachat.models import Function
return [Function.model_validate(t["function"]) for t in tools]
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
from gigachat.models import Chat
chat = Chat(
messages=self.to_messages(messages),
functions=self.to_functions(tools),
function_call="auto",
temperature=0.1,
max_tokens=900,
)
try:
resp = await self._get_client().achat(chat)
except Exception as exc: # noqa: BLE001 — network/auth/HTTP errors of the SDK
raise ProviderError(type(exc).__name__) from exc
msg = resp.choices[0].message
calls = []
if msg.function_call is not None:
args = msg.function_call.arguments or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
calls.append(ToolCall(id=msg.function_call.name, name=msg.function_call.name, arguments=args))
usage = getattr(resp, "usage", None)
log.info("gigachat agent ok calls=%d tokens=%s", len(calls), getattr(usage, "total_tokens", None))
return AssistantTurn(content=msg.content or None, tool_calls=calls)
# ------------------------------------------------------------------------------------------------ offline mock # ------------------------------------------------------------------------------------------------ offline mock
_DRUG_WORDS = re.compile( _DRUG_WORDS = re.compile(
+45 -5
View File
@@ -10,6 +10,7 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.integrations.base import integrations
from app.integrations.emias_record import PatientRecord, emias_records 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.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve from app.llm.rag import retrieve
@@ -77,8 +78,9 @@ TOOL_SPECS: list[dict[str, Any]] = [
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {"drug": {"type": "string"}}, "properties": {
"required": ["drug"], "drug": {"type": "string", "description": "Название препарата; пусто — текущее назначение пациента"}
},
}, },
}, },
}, },
@@ -157,7 +159,12 @@ def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]: async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name) 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: if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."} return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record() rec = await ctx.record()
@@ -165,15 +172,48 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
return {"found": True, "inn": entry.inn, "record": False, "findings": []} return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source) ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта") ctx.cite("ЕМИАС: медицинская карта")
findings = check_patient(entry, rec, load_db()) 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 "в примере состава этого вещества нет; состав зависит от производителя."
)
instruction = await _instruction(ctx, entry.inn)
return { return {
"found": True, "found": True,
"inn": entry.inn, "inn": entry.inn,
"record": True, "record": True,
"instruction": instruction, # ГРЛС excerpts: onset, side effects, discontinuation, administration
"findings": [f.model_dump() for f in findings], "findings": [f.model_dump() for f in findings],
"note": "Пустой список не означает, что препарат безопасен: решение о приёме принимает врач.", "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 не означает, что препарат безопасен: решение о приёме принимает врач.",
} }
INSTRUCTION_SECTIONS = ("onset", "side_effects", "discontinuation", "administration", "alcohol")
async def _instruction(ctx: ToolContext, inn: str) -> dict[str, str]:
"""ГРЛС excerpts for the drug (no doses in mg — see data/meds/grls)."""
try:
sections = await integrations.drug_info.sections(inn)
except Exception as exc: # noqa: BLE001
log.error("drug_info unavailable: %s", type(exc).__name__)
return {}
out = {s.section: s.text for s in sections if s.section in INSTRUCTION_SECTIONS}
if out:
ctx.cite(sections[0].source.split(",")[0] + ": инструкция к препарату")
return out
def _json(data: Any) -> str: def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False) return json.dumps(data, ensure_ascii=False)
+18 -6
View File
@@ -30,13 +30,25 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the # In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable. # API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__) log.error("db init failed, continuing without db: %s", type(exc).__name__)
if settings.max_enabled:
raise # MAX requires persistent bindings and a delivery queue.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34). # Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start() max_channel = None
await telegram.start() try:
yield await telegram.start()
await telegram.stop() if settings.max_enabled:
await scheduler.stop() from app.channels import max_bot
await dispose()
max_channel = max_bot
await max_channel.start()
await scheduler.start()
yield
finally:
await scheduler.stop()
if max_channel is not None:
await max_channel.stop()
await telegram.stop()
await dispose()
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan) app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
+22 -9
View File
@@ -15,7 +15,9 @@ from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.core import clock 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.intake_runtime import due_scales, intake_enabled, load_runtime
from app.core.questionnaire import load_policy
from app.db import repo from app.db import repo
from app.db.clinical import PatientProfile from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask from app.db.intake import QuestionnaireTask
@@ -45,16 +47,18 @@ async def plan():
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None: if initial is None:
continue continue
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day]) diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
last_activity = max([*diaries, initial.day])
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis")) 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: 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) 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"): if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
continue continue
if local.hour >= p.daily_hour and not any( done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows # 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)):
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now) if local.hour >= hour and kind not in done:
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
last = {} last = {}
for row in rows: for row in rows:
if row.status != "complete": if row.status != "complete":
@@ -82,12 +86,21 @@ async def plan():
after_crisis=crisis_date, after_crisis=crisis_date,
policy=p, policy=p,
) )
if scales or local.hour >= p.daily_hour: # Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now) 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: for sid in scales:
if sid == "pcl5" and initial.answers.get("trauma") != 1: if wanted[sid]:
continue await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
async def deliver(): async def deliver():
+17 -4
View File
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
sex = profile.sex if profile else None sex = profile.sex if profile else None
if sex is None and prior: if sex is None and prior:
sex = prior.details.get("sex") sex = prior.details.get("sex")
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
row = QuestionnaireRecord( row = QuestionnaireRecord(
id=uuid4().hex, id=uuid4().hex,
token=ctx.token, token=ctx.token,
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
version=content()["version"], version=content()["version"],
policy_version=load_policy().version, policy_version=load_policy().version,
started_at=ctx.now, started_at=ctx.now,
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(), day=day,
status="active", status="active",
answers={}, answers={},
details={ details={
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")), "weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {}, "answer_metadata": {},
"task_id": task.id if task else None, "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) ctx.session.add(row)
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
return row return row
def save_answer(row, question, value, now): def save_answer(row, question, value, now, *, derived=False):
"""Anchor recall windows to administration, never to the day of a resumed answer.""" """Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day day = row.day
starts = { starts = {
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
"period": question.period, "period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None, "period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(), "period_end": day.isoformat(),
"source": "patient", "source": "derived" if derived else "patient",
"derived": False, "derived": derived,
}, },
}, },
} }
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
return row 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): async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally # PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction. # use the engine's in-process lock. Consent and answers share this transaction.
+73 -178
View File
@@ -1,189 +1,84 @@
version: diary-v2-2026-10-03 # Stage 2 daily diary — «Этап 2: наблюдение после диагноза» (@Andrey, 2026-10-03), p.1–3.
core: # Every block comes from an instrument checked for frequent use; there are no own questions. No validated thresholds
- id: mood # exist for daily data: the diary shows a trend and brings a full scale forward, routes come from full scales only.
text: 'Какое у Вас сегодня настроение? # Modules follow the screening result of stage 1 (profile modules): depression → «depression», PTSD → «veteran».
version: diary-v3-2026-10-04
0 — самое плохое, 10 — самое хорошее.' # Core — everyone. Morning block after waking (the sleep diary is built for it), evening block at 20:00.
options: morning:
- 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: sleep_hours - id: sleep_hours
text: Сколько часов Вы спали прошлой ночью? source: Consensus Sleep Diary
text: Сколько часов Вы спали этой ночью?
numeric: true numeric: true
- id: rested max: 24
text: 'Насколько Вы выспались? - id: sleep_quality
source: Consensus Sleep Diary
0 — совсем не выспались, 4 — полностью выспались.' text: Как Вы оцениваете качество своего сна?
options: options:
- value: 0 - {value: 0, label: Очень плохо}
label: '0' - {value: 1, label: Плохо}
- value: 1 - {value: 2, label: Удовлетворительно}
label: '1' - {value: 3, label: Хорошо}
- value: 2 - {value: 4, label: Очень хорошо}
label: '2' - id: alcohol_portions
- value: 3 source: принцип Timeline Followback; порция по RUS-AUDIT
label: '3' text: |-
- value: 4 Сколько стандартных порций алкоголя Вы выпили вчера?
label: '4'
Одна порция — 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 - id: medication
text: Приняли ли Вы сегодня лекарства по схеме? source: КР ПТСР — дневник приёма лекарств
text: Приняли ли Вы сегодня лекарства, назначенные врачом?
options: options:
- value: full - {value: 'yes', label: Да}
label: По схеме - {value: 'no', label: Нет}
- value: partial - {value: not_prescribed, label: Лекарства не назначены}
label: Частично
- value: none
label: Нет
- value: not_prescribed
label: Не назначены
- id: medication_reason - id: medication_reason
text: Почему не приняли лекарства по схеме? source: КР ПТСР — дневник приёма лекарств
text: Почему не приняли?
options: options:
- value: forgot - {value: forgot, label: Забыл(а)}
label: Забыл(а) - {value: side_effects, label: Побочные эффекты}
- value: side_effects - {value: declined, label: Решил(а) не принимать}
label: Побочные эффекты - {value: ran_out, label: Закончились}
- value: declined
label: Не хочу # Depression module: PHQ-9 items with the official Russian text (config/instruments/intake_forms.yaml), answer
- value: ran_out # options adapted to one day. Three items a day in rotation: day 1 — items 1–3, day 2 — 4–6, day 3 — 7–9.
label: Закончились # Item 9 ≥1 → stop and the crisis protocol (stage 1 rule).
- value: other
label: Другая причина
- id: side_effects
text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
options: &id001
- value: 1
label: Да
- value: 0
label: Нет
- id: substances
text: Употребляли ли Вы сегодня алкоголь или другие вещества?
options: *id001
- id: comment
text: Что хотите передать врачу?
optional: true
ptsd:
- id: intrusions
text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
options: &id002
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
- id: nightmares
text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
options: *id002
- id: avoidance
text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
options: *id002
- id: hypervigilance
text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
options: *id002
- id: irritability
text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
options: *id002
depression: depression:
- id: interest form: phq9
text: 'Насколько Вам сегодня было интересно и приятно что-то делать? items_per_day: 3
question: 'Насколько сегодня Вас беспокоило:'
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: depressed
text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
options: *id002
- id: energy
text: 'Насколько у Вас сегодня были силы и энергия?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: planned_activity
text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
options: *id001
suicide:
- id: death_thoughts
text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
options: *id001
- id: thoughts_intensity
text: 'Если да — насколько сильными они были?
0 — совсем слабые, 10 — максимально сильные.'
options: options:
- value: 0 - {value: 0, label: Совсем нет}
label: '0' - {value: 1, label: Немного}
- value: 1 - {value: 2, label: Умеренно}
label: '1' - {value: 3, label: Сильно}
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: hope
text: 'Насколько Вы верите, что станет лучше?
0 — совсем не верю, 10 — полностью верю.' # PTSD module: short 4-item PCL-5 (Price et al., 2016), one item per cluster B, C, D, E; «today» instead of «the
options: # past month», options 0–4 of PCL-5. Item numbers must still be checked against the article (PDF p.7, «Медику»).
- value: 0 ptsd:
label: '0' form: pcl5
- value: 1 items: [pcl5_1, pcl5_7, pcl5_10, pcl5_18]
label: '1' question: 'Насколько сильно сегодня Вас беспокоили:'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: burden
text: Чувствовали ли Вы себя обузой для близких?
options: *id001
weekly: true
+17 -7
View File
@@ -1,17 +1,27 @@
version: intake-v2-2026-10-03 version: intake-v3-2026-10-04
enabled: false enabled: false
approval: null approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness. # Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null content_review: null
contact_review: null contact_review: null
timezone: Europe/Moscow timezone: Europe/Moscow
daily_hour: 19 # Stage 2 diary («Этап 2: наблюдение после диагноза»): morning block after waking, evening block at 20:00.
mood_baseline_days: 7 morning_hour: 8
mood_bad_days: 3 evening_hour: 20
mood_drop: 3 # Personal norm = the first `norm_days` of observation; compared with the mean of the last `week_days`.
sleep_bad_days: 3 # 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 sleep_hours_below: 4
nightmares_at: 3 alcohol_heavy_portions: 6 # ≥6 standard drinks a day = heavy day (volumes of RUS-AUDIT item 3)
alcohol_heavy_days: 2 # heavy days in a week → full RUS-AUDIT
adherence_below: 0.8 # share of days with medication taken over a week
missed_medication_days: 3 missed_medication_days: 3
high_risk_silence_days: 2 high_risk_silence_days: 2
medication_days: [7, 14, 28] medication_days: [7, 14, 28]
+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
+4
View File
@@ -14,7 +14,11 @@ sex: "Укажите пол для интерпретации алкогольн
tbi: "Были ли у Вас контузия или черепно-мозговая травма?" tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?" psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже." missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
side_effects: "Беспокоили ли Вас за последнее время побочные эффекты лекарств?"
numeric_portions: "Введите число стандартных порций, например 0 или 2."
diary_done: "Дневник на сегодня уже заполнен. Спасибо!"
periods: periods:
since_last_contact: "С момента Вашего последнего ответа на эти вопросы."
past_month: "За последний месяц." past_month: "За последний месяц."
lifetime: "За всю жизнь." lifetime: "За всю жизнь."
past_3_months: "За последние три месяца." past_3_months: "За последние три месяца."
+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: "отменён"
+11 -8
View File
@@ -42,7 +42,10 @@ def dose_strip_html(grid: h.DoseGrid, start: date | None) -> str:
f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>' f'<span><i style="border:1.5px dashed var(--grey)"></i>ожидается · {c["pending"]}</span>'
"</div>" "</div>"
) )
return f'<div class="mt-doses" style="grid-template-columns:{cols}">{"".join(cells)}</div>{legend}' return (
f'<div class="mt-dose-scroll"><div class="mt-doses" style="grid-template-columns:{cols}">'
f"{''.join(cells)}</div></div>{legend}"
)
def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str: def _tile(label: str, value: str, hint: str = "", tone: str = "blue") -> str:
@@ -181,7 +184,7 @@ def render(card: PatientCard, today: date | None) -> None:
main, side = st.columns([1.75, 1], gap="large") main, side = st.columns([1.75, 1], gap="large")
with main: with main:
with st.container(border=True): with st.container(border=True, key="panel-scales"):
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")): for col, scale_id, title in ((c1, "phq9", "PHQ-9"), (c2, "pcl5", "PCL-5")):
fig = charts.scale_figure(card.series, scale_id, title, start) fig = charts.scale_figure(card.series, scale_id, title, start)
@@ -191,22 +194,22 @@ def render(card: PatientCard, today: date | None) -> None:
else: else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}") st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key=f"chart-{scale_id}")
st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.") st.caption("◇ — внеплановое заполнение (по триггеру правил). Полосы — градации тяжести из config/scales.")
with st.container(border=True): with st.container(border=True, key="panel-wellbeing"):
fig = charts.wellbeing_figure(card.series, start) fig = charts.wellbeing_figure(card.series, start)
if fig is None: if fig is None:
st.caption("Ежедневных чек-инов пока нет") st.caption("Ежедневных чек-инов пока нет")
else: else:
st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing") st.plotly_chart(fig, config=theme.PLOTLY_CONFIG, key="chart-wellbeing")
st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.") st.caption("Красная зона настроения ≤ 3 (3 дня → внеплановый PHQ-9); пунктир — сон 4 ч.")
with st.container(border=True): with st.container(border=True, key="panel-doses"):
st.html('<div class="mt-panel-title">Календарь приёма</div>') st.html('<div class="mt-panel-title">Календарь приёма</div>')
st.html(dose_strip_html(grid, start)) st.html(dose_strip_html(grid, start))
with side: with side:
with st.container(border=True): with st.container(border=True, key="panel-alerts"):
_alerts_feed(card.alerts, today) _alerts_feed(card.alerts, today)
with st.container(border=True): with st.container(border=True, key="panel-summary"):
_summary(card.summary) _summary(card.summary)
with st.container(border=True): with st.container(border=True, key="panel-safety"):
_safety_plan(card.safety_plan) _safety_plan(card.safety_plan)
with st.container(border=True): with st.container(border=True, key="panel-prescriptions"):
_prescriptions(card) _prescriptions(card)
+1 -1
View File
@@ -51,7 +51,7 @@ def render_playback_panel(persona: dict[str, Any]) -> None:
token = persona["token"] token = persona["token"]
scenario, source = w.scenario_for(persona) scenario, source = w.scenario_for(persona)
state = w.playback_state(token) state = w.playback_state(token)
st.subheader("🎬 Сценарий демо") st.subheader("Сценарий демо")
_scenario_source_controls(persona, source) _scenario_source_controls(persona, source)
if scenario.errors: if scenario.errors:
with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days): with st.expander(f"⚠️ Замечания к сценарию: {len(scenario.errors)}", expanded=not scenario.days):
+2 -3
View File
@@ -368,10 +368,9 @@ def render_doctor_panel(highlight_token: str | None, *, key: str = "doc") -> Non
] ]
for a in fresh_acute[:1]: for a in fresh_acute[:1]:
st.markdown( st.markdown(
"<div style='background:#c62828;color:#fff;padding:18px 20px;border-radius:12px;" "<div class='mt-banner t-red mt-console-alert'><div>"
"font-size:1.35rem;font-weight:700;margin-bottom:8px'>"
f"🚨 ОСТРЫЙ АЛЕРТ · {_escape(a.get('patient_name', ''))} · {_fmt_ts(a.get('ts'))}<br>" 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>", f"<div class='mt-sub'>{_escape(a.get('reason', ''))}</div></div></div>",
unsafe_allow_html=True, unsafe_allow_html=True,
) )
c1, c2 = st.columns(2) c1, c2 = st.columns(2)
@@ -68,7 +68,7 @@ def _reset() -> None:
def _result(res: ConnectPatientOut, name: str) -> None: def _result(res: ConnectPatientOut, name: str) -> None:
link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501")) link = h.absolute_link(res.deep_link, os.environ.get("DASHBOARD_PUBLIC_URL", "http://localhost:8501"))
with st.container(border=True): with st.container(border=True, key="panel-connect-result"):
st.html( st.html(
f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>' f'<div class="mt-kicker">Пациент подключён · {escape(name)}</div>'
'<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит ' '<div class="mt-sub" style="margin:4px 0 10px">Покажите QR пациенту или продиктуйте код — бот спросит '
@@ -108,7 +108,7 @@ def main() -> None:
st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,)) st.button("Заполнить демо-данными (Андрей)", icon=":material/auto_fix_high:", on_click=_fill_demo, args=(today,))
left, right = st.columns(2, gap="large") left, right = st.columns(2, gap="large")
with left, st.container(border=True): with left, st.container(border=True, key="panel-connect-person"):
st.html('<div class="mt-panel-title">Пациент</div>') st.html('<div class="mt-panel-title">Пациент</div>')
st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО") st.text_input("Имя и инициал фамилии", key="cp_name", placeholder="Андрей С.", help="Без полного ФИО")
a, b = st.columns(2) a, b = st.columns(2)
@@ -128,7 +128,7 @@ def main() -> None:
key="cp_modules", key="cp_modules",
format_func=lambda m: h.MODULE_LABELS[m], format_func=lambda m: h.MODULE_LABELS[m],
) )
with right, st.container(border=True): with right, st.container(border=True, key="panel-connect-prescription"):
st.html('<div class="mt-panel-title">Назначение</div>') st.html('<div class="mt-panel-title">Назначение</div>')
d1, d2 = st.columns([1.4, 1]) d1, d2 = st.columns([1.4, 1])
with d1: with d1:
+17 -7
View File
@@ -14,21 +14,31 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402 from console import widgets as w # noqa: E402
from console.playback_ui import render_playback_panel # noqa: E402 from console.playback_ui import render_playback_panel # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide") st.set_page_config(page_title="Демо-консоль", page_icon="🎬", layout="wide")
st.html(theme.CSS)
st.html(
ui.page_header(
"Демо-консоль",
"Чат пациента и сценарий сопровождения. Прототип на синтетических данных.",
kicker="Демонстрация",
)
)
persona = w.select_persona() persona = w.select_persona()
w.render_connect_hero() w.render_connect_hero()
w.show_flash() w.show_flash()
st.sidebar.caption(f"API: {w.get_api().base_url}") st.sidebar.caption(f"API: {w.get_api().base_url}")
st.title("Демо-консоль") with st.container(border=True, key="panel-console-clock"):
st.caption("Прототип на синтетических данных. Время — виртуальные демо-часы (ADR-005).") w.render_clock_bar(persona, key="console-clock")
w.render_clock_bar(persona, key="console-clock")
st.divider()
chat_col, play_col = st.columns([1.05, 1], gap="large") chat_col, play_col = st.columns([1.05, 1], gap="large")
with chat_col: with chat_col, st.container(border=True, key="panel-console-chat"):
st.subheader(f"💬 Чат пациента — {persona.get('display_name') or persona['token']}") st.subheader(f"Чат пациента — {persona.get('display_name') or persona['token']}")
w.render_chat(persona["token"], height=560, key="console-chat") w.render_chat(persona["token"], height=560, key="console-chat")
with play_col: with play_col, st.container(border=True, key="panel-console-scenario"):
render_playback_panel(persona) render_playback_panel(persona)
+10 -10
View File
@@ -15,12 +15,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import widgets as w # noqa: E402 from console import widgets as w # noqa: E402
from dashboard import theme # noqa: E402
from dashboard.components import ui # noqa: E402
st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed") st.set_page_config(page_title="Сплит-экран", page_icon="🖥️", layout="wide", initial_sidebar_state="collapsed")
st.markdown( st.html(theme.CSS)
"<style>.block-container{padding-top:1.2rem;max-width:100%} " st.html(ui.page_header("Сплит-экран", "Пациент и врач: сопровождение в реальном времени.", kicker="Демонстрация"))
"[data-testid='stMarkdownContainer'] p, [data-testid='stMarkdownContainer'] li{font-size:1.08rem}</style>",
unsafe_allow_html=True,
)
persona = w.select_persona() persona = w.select_persona()
w.show_flash() w.show_flash()
@@ -44,15 +44,15 @@ if clicked and nxt is not None:
st.rerun() st.rerun()
left, right = st.columns([1.1, 1], gap="large") left, right = st.columns([1.1, 1], gap="large")
with left: with left, st.container(border=True, key="panel-split-patient"):
url = w.patient_web_url() url = w.patient_web_url()
if url: if url:
st.markdown(f"#### 📱 Пациент — {persona.get('display_name') or token}") st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
st.iframe(url, height=760) st.iframe(url, height=760)
st.caption(f"Веб-приложение пациента: {url}") st.caption(f"Веб-приложение пациента: {url}")
else: else:
st.markdown(f"#### 💬 Пациент — {persona.get('display_name') or token}") st.markdown(f"#### Пациент — {persona.get('display_name') or token}")
w.render_chat(token, height=600, key="split-chat") w.render_chat(token, height=600, key="split-chat")
with right: with right, st.container(border=True, key="panel-split-doctor"):
st.markdown("#### 🩺 Кабинет врача — д-р Смирнова Е. А.") st.markdown("#### Кабинет врача — д-р Смирнова Е. А.")
w.polled_doctor_panel(token, key="split-doc") w.polled_doctor_panel(token, key="split-doc")
+23 -6
View File
@@ -138,8 +138,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-empty p {{ font-size:.85rem; margin:8px 0 0; }} .mt-empty p {{ font-size:.85rem; margin:8px 0 0; }}
/* ---------- chips, badges, bars ---------- */ /* ---------- chips, badges, bars ---------- */
.mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 999px; margin-right: 4px; .mt-chip {{ display:inline-block; font-size:.72rem; padding: 1px 8px; border-radius: 5px; margin-right: 4px;
background: var(--blue-tint); color: var(--blue-ink); font-weight: 500; }} background: #edf3ee; color: #38634b; font-weight: 500; }}
.mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px; .mt-badge {{ display:inline-flex; align-items:center; gap:6px; font-size:.7rem; font-weight:550; padding: 5px 8px;
border-radius: 5px; white-space: normal; }} border-radius: 5px; white-space: normal; }}
.mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }} .mt-badge.t-red {{ background: var(--red-tint); color: var(--red-ink); }}
@@ -166,7 +166,7 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-banner b {{ color: inherit; }} .mt-banner b {{ color: inherit; }}
.mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none; .mt-pulse {{ width: 12px; height: 12px; border-radius: 50%; background: var(--red); margin-top: 5px; flex: none;
box-shadow: 0 0 0 4px #faeeee; }} box-shadow: 0 0 0 4px #faeeee; }}
.mt-panel-title {{ font-size: .8rem; text-transform: uppercase; letter-spacing: .07em; font-weight: 650; .mt-panel-title {{ font-size: .82rem; text-transform: uppercase; letter-spacing: .03em; font-weight: 550;
color: var(--ink-3); margin: 2px 0 8px; }} color: var(--ink-3); margin: 2px 0 8px; }}
.mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }} .mt-alert {{ border-left: 4px solid var(--grey); padding: 4px 0 4px 12px; }}
.mt-alert.t-red {{ border-left-color: var(--red); }} .mt-alert.t-red {{ border-left-color: var(--red); }}
@@ -175,9 +175,9 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }} .mt-alert .when {{ color: var(--ink-3); font-size: .78rem; }}
.mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }} .mt-alert .why {{ color: var(--ink); font-weight: 600; font-size: .92rem; margin: 2px 0; }}
.mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }} .mt-alert.acked .why {{ color: var(--ink-3); font-weight: 500; }}
.mt-summary {{ background: var(--blue-tint); border-radius: 12px; padding: 14px 16px; color: var(--ink); .mt-summary {{ background: #f4f6f2; border:1px solid var(--line); border-radius: 12px; padding: 14px 16px;
line-height: 1.5; font-size: .93rem; }} color: var(--ink); line-height: 1.5; font-size: .93rem; }}
.mt-summary .disc {{ color: var(--blue-ink); font-size: .76rem; margin-top: 8px; }} .mt-summary .disc {{ color: var(--ink-2); font-size: .76rem; margin-top: 8px; }}
.mt-plan ol {{ margin: 0; padding-left: 1.2rem; }} .mt-plan ol {{ margin: 0; padding-left: 1.2rem; }}
.mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }} .mt-plan li {{ margin: 3px 0; color: var(--ink); font-size: .9rem; }}
@@ -195,6 +195,21 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px; .mt-legend i {{ display:inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 5px;
vertical-align: -1px; }} vertical-align: -1px; }}
/* ---------- internal pages ---------- */
div[class*="st-key-panel-"] {{ background:var(--surface); border:1px solid var(--line); border-radius:12px;
padding:20px; }}
[data-testid="stExpander"] {{ background:var(--surface); border-color:var(--line); border-radius:12px; }}
[data-testid="stChatMessage"] {{ background:#f4f6f2; border:1px solid var(--line); border-radius:12px; }}
[data-testid="stChatInput"] {{ background:var(--surface); border:1px solid var(--line); border-radius:8px; }}
[data-testid="stNumberInputContainer"], [data-testid="stDateInput"] input {{
background:var(--surface); color:var(--ink); border-color:var(--line); border-radius:8px; }}
[data-testid="stApp"] [data-variant="pills"][data-selected] {{ background:#edf3ee; color:#285e4e;
border-color:#b4c6ba; }}
.mt-console-alert {{ font-size:1.1rem; font-weight:600; }}
.mt-console-alert .mt-sub {{ color:var(--red-ink); font-weight:400; }}
.mt-dose-scroll {{ overflow-x:auto; padding-bottom:4px; }}
.mt-doses {{ min-width:max-content; }}
/* ---------- connect result ---------- */ /* ---------- connect result ---------- */
.mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink); .mt-code {{ font-size: 2.4rem; font-weight: 700; letter-spacing: .12em; color: var(--ink);
font-variant-numeric: tabular-nums; }} font-variant-numeric: tabular-nums; }}
@@ -222,6 +237,8 @@ div[class*="st-key-prow-"] {{ border-top:1px solid var(--line); padding:18px 0 4
.mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }} .mt-mobile-label {{ display:block; color:var(--ink-3); font-size:.68rem; margin-bottom:5px; }}
.mt-row-details {{ grid-template-columns:1fr; gap:12px; }} .mt-row-details {{ grid-template-columns:1fr; gap:12px; }}
.mt-list-footer {{ flex-wrap:wrap; }} .mt-list-footer {{ flex-wrap:wrap; }}
.mt-head .mt-side {{ text-align:left; }}
div[class*="st-key-panel-"] {{ padding:16px; }}
.mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }} .mt-tip:hover::after, .mt-tip:focus-visible::after {{ display:none; }}
}} }}
@media (prefers-reduced-motion: reduce) {{ @media (prefers-reduced-motion: reduce) {{
File diff suppressed because it is too large Load Diff
+6
View File
@@ -3,6 +3,12 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста».
**Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется.
**Причина:** пациенту нужен понятный итог и путь дальше, а не тупик; решения о риске и маршруте при этом остаются детерминированными (ADR-007).
**Последствия:** отступления от присланного PDF сохранены по `docs/clinical/README.md`: TSQ вместо PC-PTSD-5 (v2 PDF), RUS-AUDIT-S — пункты 3/9/10 (ВОЗ). Открыто: номера 4 пунктов короткой PCL-5 (Price et al., 2016), официальная форма C-SSRS Since Last Contact (сейчас — тексты Recent с периодом «с последнего ответа»), пороги «роста» модулей дневника — согласовать с врачом.
## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами ## 2026-10-04 — ADR-011: База знаний (RAG) — лексический поиск BM25 и экстрактивные ответы с цитатами
**Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34). **Контекст:** агенту нужны ответы по клиническим рекомендациям (КР ПТСР 2023, КР Депрессия 2024) со ссылкой на источник (#60). Эмбеддинги требуют либо сети (GigaChat Embeddings), либо тяжёлых зависимостей (torch, модель на сотни МБ). При этом демо должно работать офлайн (ADR-004), а `pyproject.toml` заморожен (#34).
**Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача. **Решение:** PDF переводятся в txt один раз, офлайн (`docs/source/rag/pdf_to_txt.py`: таблицы → markdown, маркеры страниц, текст блок-схем перенесён вручную). Корпус лежит в `config/rag/`. Поиск — BM25 по основам слов (свой стеммер Snowball) с синонимами и переранжированием по самому плотному отрывку. Ответ **экстрактивный**: показываются только предложения из найденного фрагмента с цитатой «[КР ПТСР 2023, стр. N]». LLM может лишь упростить их для пациента, а перефразировка с новым содержанием отбраковывается. Если отрывки покрывают слишком малую долю вопроса (`min_coverage`), ответ — «ответа нет, уточните у врача». Пациенту не показываются предложения о назначении и дозах. Внутренние документы команды (`audiences: [team]`) не доходят ни до пациента, ни до врача.
+142
View File
@@ -0,0 +1,142 @@
# RAG по клиническим рекомендациям Минздрава
> Статус: черновик подхода, 2026-10-04. Реализация — отдельной задачей (lane B, `app/llm/rag.py` по SPEC §структура).
> Пример исходника: КР «Посттравматическое стрессовое расстройство», ID 753, 2023 (Российское общество психиатров).
## Принцип
Файлов мало, глубина нужна максимальная, поэтому мы **не** делаем классический RAG «нарезка по N токенов → эмбеддинги → top-k».
Каждая КР один раз превращается в **размеченную и вычитанную базу знаний** из смысловых единиц. Поиск по ней почти тривиален, а
каждый ответ проверяется по цитате.
## 1. Кому отвечает RAG
КР написаны для врачей: дозы, препараты off-label (`#`), показания к госпитализации. Пересказывать это пациенту нельзя —
это противоречит нашим guardrails (`app/llm/med_qa.py`: о препаратах — только по инструкции ГРЛС и без доз).
| Потребитель | Что можно отдавать из КР |
|---|---|
| Пациент (бот) | Только `Приложение В. Информация для пациента` и психообразование. Без доз и названий препаратов |
| Врач (дашборд) | Всё, с цитатой, уровнем убедительности (УУР) и достоверности доказательств (УДД) |
| Команда (design-time) | Обоснование порогов в `config/` (TSQ ≥ 6 — Прил. Г1, градации шкалы Бека — Г10): ссылки на источник, а не runtime-RAG |
У каждой единицы есть поле `audience`. Фильтр по нему применяется **до** поиска, а не через промпт LLM.
## 2. Разбивка на смысловые единицы
Структура КР Минздрава стабильна, используем её как схему.
| `type` | Пример из КР ПТСР | Как хранить |
|---|---|---|
| `recommendation` | «Пациентам с ПТСР … рекомендуется начинать терапию с СИОЗС…» | Тезис + УУР/УДД + комментарий + номера источников |
| `definition` / `criteria` | Критерии МКБ-10 А–Д, кластеры МКБ-11 | По одному критерию на единицу |
| `dose_row` | Таблица «Рекомендуемые дозы» (Прил. А3) | Строка: препарат, диапазон, `off_label`, `zhnvlp` (**) |
| `scale` | TSQ, CAPS, IES-R, BHS, TOP-8 (Прил. Г1–Г11) | Пункты + ключ интерпретации как данные |
| `algorithm` | Прил. Б и Б1 (в PDF это картинки) | Переписать вручную в шаги и ветвления |
| `patient_info` | Прил. В | Абзацы с `audience: patient` |
| `quality_criterion` | «Критерии оценки качества медицинской помощи» | По строке таблицы |
Пример единицы:
```json
{
"id": "kr753.3.2.rec01",
"doc": {"kr_id": 753, "title": "ПТСР", "approved": "2023", "revise_by": "2025"},
"path": ["3. Лечение", "3.2. Психофармакотерапия", "Терапия первой линии"],
"type": "recommendation",
"text": "Пациентам с ПТСР в качестве препаратов первой линии ... рекомендуется начинать терапию с СИОЗС ...",
"comment": "После установления диагноза ... эффективность оценивается через 4–6 недель ...",
"uur": "A",
"udd": 1,
"refs": [51, 53, 302],
"drugs": ["пароксетин", "сертралин", "флуоксетин", "венлафаксин"],
"contains_dose": true,
"off_label": ["флуоксетин", "венлафаксин"],
"audience": ["doctor"],
"pages": [17, 18]
}
```
Комментарий хранится вместе с тезисом: по отдельности они теряют смысл, а основная глубина КР — именно в комментариях.
## 3. Пайплайн подготовки: автоматика, затем вычитка
Файлов мало, поэтому ручная вычитка реальна и даёт больше всего качества. Повторяем практику `docs/clinical/`: хеши
исходников, `review-status.json`.
1. **Источник.** Первоисточник — cr.minzdrav.gov.ru, а не перевёрстки (пример выше — перевёрстка с disuria.ru). Храним sha256.
2. **Очистка.** Убрать колонтитулы («Страница N из 117», рекламу сайта), склеить переносы и разрывы страниц
(слипания вида «клиникопсихопатологических»), собрать таблицы, разорванные между страницами.
3. **Парсер по шаблону.** Абзац «- Пациентам…» до строки «Уровень убедительности рекомендаций X (уровень достоверности
доказательств N)» — одна единица `recommendation`. Следующий за ней блок «Комментарии:» — её поле `comment`.
4. **Список литературы не индексируем.** Это шум для поиска; номера источников остаются в `refs`.
5. **Ручная вычитка.** В первую очередь — единицы для пациента и всё, где есть числа. В КР ПТСР уже видны ошибки самого
источника, которые автоматика пропустит:
- в таблице критериев качества перепутаны колонки УДД и УУР;
- раздел 6.1 встречается дважды;
- опечатка «ценить суицидальный риск» вместо «оценивать».
6. **Словарь аббревиатур и синонимов** — из списка сокращений в начале каждой КР. Нужен для расширения запросов:
СИОЗС ↔ сертралин/пароксетин, ДПДГ ↔ EMDR, ТФ-КПТ и т.д.
Раскладка по ADR-008:
```
data/kb/sources/ исходные PDF + sha256
data/kb/units/*.jsonl вычитанные единицы (в git)
data/kb/review-status.json статус вычитки по единицам
data/eval/kb_questions.yaml эталонные вопросы
```
## 4. Поиск
Корпус маленький, тяжёлая инфраструктура не нужна.
- **Фильтр по метаданным до поиска:** `audience`, `doc`, `type`. Вопрос пациента о дозе — отказ без поиска.
- **Гибридный поиск:**
- BM25 с русской лемматизацией (pymorphy3) и расширением запроса по словарю аббревиатур;
- эмбеддинги — только GigaChat Embeddings или локальная модель (guardrails 4 и 9; для офлайн-демо — только локальная).
- **Small-to-big.** Найден тезис — в контекст LLM уходит тезис с комментарием и заголовками родительского раздела. При
нашем объёме допустимо отдавать раздел целиком.
- **Порог уверенности.** Лучшая единица ниже порога — ответ «В клинических рекомендациях ответа на этот вопрос нет»
(по аналогии с `fallback` в `config/texts/med_qa.yaml`).
## 5. Генерация строго по источнику
Переиспользуем механизмы `app/llm/med_qa.py`:
- LLM получает пронумерованные единицы и обязана ссылаться на `id` в каждом утверждении.
- **Детерминированная проверка после генерации:**
- каждое число в ответе встречается в процитированных единицах;
- доля «новых» слов ограничена (`MAX_NOVEL_STEM_SHARE`).
Не прошло — вместо пересказа отдаём саму цитату.
- Ссылка на источник («КР ПТСР 2023, разд. 3.2, УУР A») и фраза «решение принимает врач» добавляются кодом, не через LLM.
- До поиска действуют общие guardrails: проверка безопасности (`acute` → фиксированный кризисный скрипт), затем `scrub()`,
затем вызов модели только через `app/llm/gateway.py`.
## 6. Оценка качества
Эталонный набор в `data/eval/` на 50–100 вопросов с ожидаемыми `id` единиц. Вопросы трёх видов:
- прямые;
- перефразированные;
- ловушки, на которые в КР ответа нет.
Метрики:
- recall@k по `id` (есть ли нужные единицы среди первых k найденных);
- доля ответов, прошедших проверку по цитате;
- доля правильных отказов на ловушках.
Оценка запускается обычным pytest-тестом, чтобы правки разметки не ломали поиск незаметно.
## Риски и ограничения
- **Актуальность.** КР ПТСР указывает «пересмотр не позднее 2025». Перед использованием проверить в рубрикаторе Минздрава
наличие новой редакции. `approved` / `revise_by` храним в метаданных и показываем в ответе.
- **Объём к дедлайну (05.10.2026, 13:00).** Реалистичный MVP:
- 2–3 КР (ПТСР, депрессия), размеченные полуавтоматически и вычитанные;
- BM25 + фильтр по `audience`;
- строгие цитаты.
Эмбеддинги и реранкер — по остаточному принципу: глубину даёт качество разметки, а не векторная база.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+323
View File
@@ -0,0 +1,323 @@
# Запрос на мокапы: приложение пациента «Сопровождение пациента»
Хакатон MedITron, кейс SberMed AI «ИИ-помощник пациента». По мокапам сразу пишется код (React, затем мобильное
приложение через Capacitor), поэтому важны точные токены, реальные тексты и все состояния экранов.
---
## 1. О продукте
**Что это.** Сервис сопровождения между визитами к врачу: напоминает о лекарствах, раз в день спрашивает о
самочувствии, по расписанию проводит короткие медицинские анкеты и сообщает врачу, если состоянию нужно
внимание. ИИ не ставит диагнозы и не лечит.
**Для кого.** Ветераны боевых действий 25–55 лет с ПТСР и депрессией, позже — их семьи. Часто уставшие,
недоверчивые к «приложениям», могут быть в тяжёлом состоянии. Пользуются в основном телефоном.
**Главная задача интерфейса.** Ежедневная отметка самочувствия и приёма лекарства занимает меньше минуты и не
вызывает тревоги. Человеку хочется её заполнять.
**Два способа входа.**
- Через ЕМИАС: врач видит ответы и получает сигнал при ухудшении.
- Анонимно: без имени и полиса, врач ничего не видит.
---
## 2. Требования к мокапам
### Формат
- Мобильные кадры **390×844**, по одному экрану на кадр.
- Для A1 и C1 дополнительно десктоп **1440×1000**.
- Отдать **PNG** и, если есть, ссылку на Figma с открытым просмотром.
- Имена файлов по ID экрана: `B3-scale.png`, `C1-today.png`, `C1-today-desktop.png`.
- Состояния — отдельными кадрами с суффиксом: `D1-mood-selected.png`, `S2-offline.png`.
### Содержание
- **Только реальные тексты на русском** из этого документа. Никаких lorem ipsum и «Заголовок».
- **Без логотипов ЕМИАС, mos.ru, Госуслуг и Минздрава.** Вход — текстовая кнопка «Войти через ЕМИАС».
- **Баллы анкет, диагнозы и уровни тяжести пациенту не показываются нигде.**
- **Кнопка «Нужна помощь»** (звонок 112) есть в шапке каждого экрана.
- Без иллюстраций людей, стоковых фото, эмодзи, градиентов, стеклянных эффектов и 3D.
- Учесть вырез экрана сверху и home indicator снизу.
### Состояния (там, где они есть у экрана)
Обычное · выбранный вариант · кнопка неактивна · загрузка · ошибка с понятным действием.
---
## 4. Общий промпт
Вставляется перед промптом любого экрана из раздела 5.
```
Спроектируй экран мобильного приложения (390×844) «Сопровождение пациента» — сервиса сопровождения
между визитами к врачу. Пользователи — ветераны боевых действий 25–55 лет с ПТСР и депрессией, часто
уставшие и недоверчивые. Цель — чтобы ежедневные отметки занимали меньше минуты и не вызывали тревоги.
Стиль: строгий государственный медицинский сервис уровня mos.ru и Госуслуг, но тёплый и спокойный.
Без логотипов ЕМИАС, mos.ru и Госуслуг. Без иллюстраций людей, стоковых фото, эмодзи, градиентов,
стекла и 3D. Много воздуха, чёткая иерархия, крупный текст.
Правила: кнопки не ниже 48 px, основная кнопка на всю ширину внизу; один вопрос — один экран,
варианты ответа — крупные карточки на всю ширину, выбранный подсвечивается цветом направления;
в шапке красная кнопка «Нужна помощь»; нижняя навигация «Сегодня · Лекарства · Сообщения · Профиль»
(кроме экранов входа и анкет). Баллы, диагнозы и оценки тяжести не показывать. Тексты на русском,
короткие, на «вы», без медицинского жаргона и без давления. Учти вырез экрана и home indicator.
```
---
## 5. Экраны и промпты
Приоритет: **волна 1** нужна в первую очередь — это ядро ежедневного сценария.
### Волна 1
**C1 · Сегодня (главная)**
```
Экран «Сегодня». Приветствие «Добрый день» и дата «Воскресенье, 4 октября». Список задач дня карточками
с временем слева и цветной полосой направления: «09:00 · Утренний приём» (лекарства, отмечено
«Принято»), «14:00 · Как вы сегодня?» (самочувствие, кнопка «Ответить», «≈ 1 минута»),
«Анкета о сне и настроении» (самочувствие, «≈ 5 минут», «до 11 октября»). Ниже блок
«Следующий визит: 12 октября, 10:30, врач Смирнова Е. А.». Быстрые действия: «Вопрос о лекарстве»,
«Написать врачу». Нижняя навигация, активна вкладка «Сегодня». Отдельно десктоп 1440×1000.
```
**C2 · Всё сделано**
```
Экран «Сегодня», когда задачи выполнены: спокойное пустое состояние «На сегодня всё. Следующее
напоминание — завтра в 09:00». Все карточки дня отмечены как выполненные. Без конфетти и наград.
```
**D1 · Настроение**
```
Первый шаг ежедневной отметки. Прогресс «1 из 5», кнопка «Назад». Вопрос крупно: «Как вы себя
чувствуете сегодня?». Шкала 0–10 крупными кнопками с цветовой градацией от тёплого красного к
зелёному, подписи по краям «Очень тяжело» и «Отлично». Состояния: ничего не выбрано; выбрано 6.
Кнопка «Дальше» внизу.
```
**D2 · Лекарство сегодня**
```
Шаг 2 из 5. «Вы приняли лекарство сегодня?» Карточки: «Да, принял(а)», «Нет, пропущу», «Не помню».
Цвет выбора — бирюзовый (лекарства).
```
**D3 · Сон и кошмары**
```
Шаг 3 из 5. «Сколько часов вы спали этой ночью?» — четыре карточки «Меньше 4», «4–6», «6–8»,
«Больше 8». Ниже второй вопрос на том же экране: «Снились ли кошмары?» — «Да» / «Нет».
```
**D4 · Алкоголь**
```
Шаг 4 из 5. «Выпивали ли вы алкоголь вчера?» — «Нет», «1–2 порции», «Больше». Нейтральный тон,
без осуждения, подпись мелко: «Ответ видит только ваш врач».
```
**D5 · Пару слов**
```
Шаг 5 из 5. «Как прошёл день? Пару слов» — поле ввода на несколько строк, подсказка «Например:
плохо спал, болит голова». Кнопки «Отправить» и «Пропустить». Клавиатура открыта.
```
**D6 · Готово**
```
Итог отметки: «Спасибо, записали». Короткая сводка ответов без оценок: «Настроение 6 из 10 ·
Лекарство принято · Сон 6–8 часов». Строка «Ваш врач увидит отметку в карточке». Пример ответа
сервиса: карточка «Эффект препарата наступает не сразу — обычно через 2–4 недели». Кнопка «На главную».
```
**E1 · Напоминание о приёме**
```
Экран, открывающийся по уведомлению. Бирюзовый акцент. «09:00 · Время утреннего приёма». Название
препарата и «1 таблетка после еды» (без дозировок в мг). Три кнопки: основная «Принял(а)»,
вторичные «Пропущу» и «Есть побочные эффекты».
```
**B3 · Вопрос анкеты (универсальный экран)**
```
Экран одного вопроса анкеты. Сверху «Вопрос 3 из 20» с полосой прогресса, «Назад» и «Пауза».
Мелко период: «За последний месяц». Вопрос крупно: «Как часто вас беспокоили повторяющиеся
тревожные воспоминания о пережитом?». Пять карточек-вариантов: «Совсем нет», «Немного»,
«Умеренно», «Сильно», «Очень сильно». Без баллов рядом с ответами. Состояния: не выбрано; выбрано
«Умеренно». Отдельно экран паузы: «Можно продолжить позже — ответы сохранены».
```
**G2 · Кризисный экран**
```
Полноэкранный экран поддержки. Спокойный, не пугающий: белый фон, красный используется только для
кнопок звонка. Текст: «Мне очень жаль, что вам сейчас так тяжело. Вы не одни». Две крупные
кнопки звонка: «112 — если есть угроза жизни прямо сейчас» и «+7 (495) 989-50-50 — Психологическая
помощь МЧС, круглосуточно». Блок дыхания: круг, который плавно расширяется и сжимается, подпись
«Вдох на 4 счёта — выдох на 6». Внизу «Я рядом».
```
### Волна 2
**A1 · Выбор входа** (уже свёрстан, нужен улучшенный вариант)
```
Стартовый экран. Заголовок «Лечение продолжается и между приёмами», подзаголовок «Напомним о
лекарствах, раз в день спросим о самочувствии и сообщим врачу, если состоянию нужно внимание».
Две карточки выбора: «Через ЕМИАС» (метка «Рекомендуем», плюсы: назначения подгружаются
автоматически; врач видит ответы и получает сигнал при ухудшении; история на любом устройстве;
кнопка «Войти через ЕМИАС») и «Анонимно» (плюсы: не нужны имя, полис и телефон; опросы и помощь в
кризисе доступны; минусы: врач не видит ответы; история только на этом устройстве; кнопка
«Продолжить анонимно»). Отдельно десктоп 1440×1000.
```
**A2 · Вход через ЕМИАС**
```
Экран входа. Плашка «Демонстрационный режим: вход подтверждается одноразовым кодом, который врач
выдаёт на приёме». Поле «Код подключения от врача» и кнопка «Сканировать QR-код». Кнопка «Войти».
Ниже разделитель «или» и список «Демо-пациенты»: строка «Андрей С. · 13-й день терапии · Войти».
Состояния: ошибка «Код не подошёл. Проверьте написание или попросите врача выдать новый код».
```
**A3 · Анонимный вход**
```
«Анонимный вход». Три пункта с галочками: что доступно, что врач не видит, что нужно согласие.
Жёлтая плашка: «История привязана к этому устройству. Если вы выйдете, восстановить её будет нельзя».
Кнопка «Начать анонимно».
```
**A4 · Согласие на обработку данных**
```
Экран согласия (152-ФЗ). Заголовок «Перед началом нужно ваше согласие». Короткие блоки с иконками:
«Что обрабатываем» — ответы о самочувствии, сне, приёме лекарств; «Кто видит» — только ваш лечащий
врач; «Как отозвать» — в любой момент в профиле. Ссылка «Полный текст согласия». Кнопки «Согласен(на)»
и «Не согласен(на)» равного веса — без давления.
```
**A5 · Возраст**
```
«Вам уже исполнилось 18 лет?» Две карточки: «Да, мне 18 или больше», «Нет, мне меньше 18».
```
**A6 · Отказ или младше 18**
```
Спокойный информационный экран без упрёка: «Без согласия мы не можем сохранять ваши ответы. Если
передумаете — нажмите „Начать заново“». Блок «Если нужна помощь сейчас» с номерами. Вариант для
младше 18: «Сервис рассчитан на взрослых» и детский телефон доверия 8-800-2000-122.
```
**B1 · Вступление к анкете**
```
«Сначала несколько вопросов». Абзац «Многие после возвращения замечают проблемы со сном,
раздражительность, тревогу — это частая реакция, и с ней можно справиться». Раскрывающиеся
вопросы FAQ: «Зачем эти вопросы?», «Кто увидит ответы?», «Можно ли прерваться?». Подпись
«≈ 10 минут, можно с перерывами». Кнопка «Начнём».
```
**B2 · Травматический опыт**
```
«Был ли у вас опыт, связанный с угрозой жизни — вашей или других людей?» Карточки «Да», «Нет»,
«Не хочу отвечать». Деликатно, без подробностей события.
```
**B4 · Доступ к оружию**
```
«Есть ли у вас дома оружие?» Карточки: «Нет», «Да, у меня», «Да, хранится у других или в сейфе»,
«Не хочу отвечать». Отдельный кадр — блок о безопасном хранении: спокойный тон, совет хранить
оружие и патроны отдельно или передать на время близкому человеку.
```
**B5 · Итог анкеты**
```
«Спасибо, анкета заполнена». Без баллов и диагнозов: «Ваш врач получит результаты к следующему
визиту». Блок «Что дальше»: ежедневная отметка в 14:00, напоминания о приёме, повторная анкета
через 2 недели. Кнопка «Перейти на главную».
```
**E2 · Причина пропуска**
```
«Почему пропускаете приём?» Карточки: «Забыл(а)», «Побочные эффекты», «Решил(а) бросить», «Другое».
Подпись: «Ответ поможет врачу подобрать лечение».
```
**E3 · Побочные эффекты**
```
«Что беспокоит?» Чипы с множественным выбором: тошнота, бессонница, сонливость, головная боль,
снижение аппетита, другое. При «Другое» — поле ввода. Кнопка «Отправить».
```
**E4 · Моя схема (вкладка «Лекарства»)**
```
Вкладка «Лекарства». Карточка препарата: название, «1 таблетка утром после еды», время 09:00.
Полоса отметок за 7 дней: принято / пропущено / нет отметки. Блок «Вопрос о лекарстве».
Строка «Схему меняет только ваш врач».
```
**E5 · Вопрос о лекарстве**
```
Поле вопроса и пример ответа: карточка «По инструкции к препарату: …» с подписью источника
«Инструкция ГРЛС». Внизу всегда: «Не меняйте схему приёма самостоятельно — решения принимает врач».
Состояние «В инструкции нет ответа — уточните у врача».
```
**F1 · Написать врачу**
```
«Сообщение врачу». Поле ввода, подсказка «Врач ответит в рабочее время». Красная плашка: «Если
помощь нужна сейчас — звоните 112». Кнопка «Отправить». Экран успеха: «Сообщение передано».
```
**F2 · Следующий визит**
```
Дата и время визита, врач, адрес поликлиники. Список «Что обсудить на приёме» — можно добавить
свой пункт. Кнопка «Добавить в календарь».
```
**G1 · Нужна помощь** (шторка снизу)
```
Нижняя шторка поверх любого экрана: «Если вам сейчас очень тяжело». Две крупные строки звонка с
номерами 112 и +7 (495) 989-50-50, кнопка «Позвонить» у каждой.
```
**G3 · План безопасности**
```
«Мой план безопасности». Шаги: «Мои тревожные сигналы», «Что мне помогает успокоиться», «Кому я
могу позвонить» (контакты), «Куда обратиться» (номера служб). Каждый шаг можно редактировать.
```
**G4 · Вопросы о безопасности**
```
Экран вопроса из опросника риска. Очень спокойная подача, без красного цвета. Подпись: «Мы
спрашиваем об этом всех — это помогает вовремя поддержать». Ответы «Да» / «Нет».
```
**H1 · Профиль**
```
Вкладка «Профиль». Способ входа (ЕМИАС или анонимно). Для анонимного — кнопка «Подключить ЕМИАС».
Настройки: время ежедневной отметки, уведомления о приёме. Разделы «Мои данные и согласие», «Выйти».
```
**H2 · Данные и согласие**
```
«Мои данные». Дата согласия, ссылка на текст. Кнопка «Отозвать согласие» с подтверждением:
«Обработка ответов прекратится, данные будут помечены к удалению». Для анонимного — «Удалить мои данные».
```
**I1 · Сообщения**
```
Вкладка «Сообщения» — чат с помощником. Сообщения помощника слева на белом, ответы пациента справа
на синем. Кнопки ответа под сообщением — карточками. Поле ввода внизу.
```
**S1–S4 · Системные состояния**
```
Четыре кадра: загрузка (скелетон главной); нет сети («Нет соединения — ответы отправятся, когда
появится сеть», кнопка «Нужна помощь» работает); ошибка («Не удалось загрузить. Повторить»);
сессия истекла («Войдите снова»).
```
---
## 6. Чек-лист приёмки
- [ ] Все кадры 390×844, имена файлов по ID.
- [ ] Цвета, шрифт и скругления — из раздела 3.
- [ ] Кнопка «Нужна помощь» на каждом экране, кроме кризисного (там она заменена звонками).
- [ ] Нигде нет баллов анкет, диагнозов и логотипов ЕМИАС, mos.ru, Госуслуг.
- [ ] Тексты реальные, на русском; у вопросов есть выбранное состояние.
- [ ] Кнопки не ниже 48 px; текст не мельче 12 px.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Deploy origin/dev to the shared server (ssh alias `vobot`, dir /srv/ptsr). Run from a clean checkout:
# infra/deploy/deploy.sh [git-ref] default: origin/dev
# Keeps /srv/ptsr/.env (secrets live only on the server) and the Postgres volume.
set -euo pipefail
REF=${1:-origin/dev}
HOST=${DEPLOY_HOST:-vobot}
DIR=/srv/ptsr
git fetch -q origin || true
SHA=$(git rev-parse --short "$REF")
echo "deploy $REF ($SHA) -> $HOST:$DIR"
git archive --format=tar "$REF" | ssh "$HOST" "rm -rf $DIR/app.new && mkdir -p $DIR/app.new && tar -x -C $DIR/app.new"
ssh "$HOST" bash -s <<SH
set -euo pipefail
cd $DIR
cp .env app.new/.env
rm -rf app.old; [ -d app ] && mv app app.old; mv app.new app
cd app
echo "$SHA" > REVISION
docker run --rm -v "\$PWD/web:/w" -w /w node:20-alpine sh -c "npm ci --no-audit --no-fund >/dev/null && npm run build >/dev/null"
docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build </dev/null
for i in \$(seq 1 40); do curl -sf 127.0.0.1:8010/health >/dev/null && break; sleep 3; done
curl -sf 127.0.0.1:8010/health && echo " api ok"
SH
+30
View File
@@ -0,0 +1,30 @@
# Server override (shared host with vobot): loopback-only ports behind host nginx, restart policy, memory caps.
# docker compose -p ptsr -f docker-compose.yml -f infra/deploy/docker-compose.server.yml up -d --build
services:
postgres:
ports: !reset []
restart: unless-stopped
mem_limit: 512m
app:
ports: !override
- "127.0.0.1:8010:8000"
restart: unless-stopped
mem_limit: 768m
dashboard:
command:
- streamlit
- run
- dashboard/app.py
- --server.port=8501
- --server.address=0.0.0.0
- --server.baseUrlPath=doctor
- --browser.gatherUsageStats=false
environment:
API_BASE_URL: http://app:8000
DASHBOARD_PUBLIC_URL: ${PUBLIC_URL:-http://localhost}/doctor
ports: !override
- "127.0.0.1:8511:8501"
restart: unless-stopped
mem_limit: 768m
+52
View File
@@ -0,0 +1,52 @@
# meditron.tenvex.ru — PTSRaiAgent prototype (synthetic data only).
# / patient web app (static, web/dist)
# /api/ FastAPI (127.0.0.1:8010)
# /doctor/ Streamlit doctor dashboard + demo console (127.0.0.1:8511, baseUrlPath=doctor)
# TLS: after the DNS A-record exists — certbot --nginx -d meditron.tenvex.ru
map $http_upgrade $ptsr_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name meditron.tenvex.ru;
access_log /var/log/nginx/ptsr-access.log;
client_max_body_size 5m;
root /srv/ptsr/app/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; # chat agent + scheduler replay on clock advance
}
location ~ ^/(docs|openapi.json|health)$ {
proxy_pass http://127.0.0.1:8010;
proxy_set_header Host $host;
}
location /doctor/ {
proxy_pass http://127.0.0.1:8511;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $ptsr_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_buffering off;
}
}
+542
View File
@@ -0,0 +1,542 @@
"""Builds data/meds/meds_reference.json — the drug reference of the medication module.
Source of every value: the two Minzdrav clinical guidelines in config/rag/corpus/ —
* «Депрессивный эпизод, рекуррентное депрессивное расстройство» (2024): прил. А3 табл. 2 (дозы),
табл. 3 (профили побочных эффектов), разд. 3.1–3.6, прил. Б5, В;
* «Посттравматическое стрессовое расстройство» (2023, ID 753): разд. 3.2, прил. А3 (дозы).
Patient side-effect items: effects rated «++»/«+++» in табл. 3 (antidepressants) or named in the КР text
(other drugs), worded for a patient. Nothing here is a recommendation: the app only follows the doctor's
prescription (module spec, «Границы»). Texts and draft parameters must be reviewed by a psychiatrist.
Run: uv run python -m scripts.build_meds_reference
"""
import json
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / "data" / "meds" / "meds_reference.json"
DEP = "КР депрессия 2024"
PTSD = "КР ПТСР 2023 (ID 753)"
# Patient wording of the КР табл. 3 columns (and of effects named in the КР text).
SE = {
"anticholinergic": "Сухость во рту или запоры",
"nausea": "Тошнота",
"sedation": "Сонливость днём",
"insomnia": "Бессонница или беспокойство",
"sexual": "Сексуальные проблемы",
"orthostatic": "Головокружение при вставании",
"weight": "Прибавка веса",
"headache": "Головная боль",
"dizziness": "Головокружение",
"coordination": "Нарушение координации",
"memory": "Снижение внимания и памяти",
"akathisia": "Беспокойство, невозможность усидеть на месте",
"eps": "Скованность или дрожь в теле",
"skin": "Кожная сыпь или зуд",
}
RF_BLEEDING = "Необычные кровотечения или синяки"
RF_EARLY = "Мысли о самоубийстве или резкое усиление тревоги в первые недели приёма"
RF_JAUNDICE = "Желтушность кожи или глаз"
RF_RASH = "Кожная сыпь"
RF_PRIAPISM = "Длительная болезненная эрекция"
RF_UTERINE = "Маточное кровотечение"
TCA_MON = "Артериальное давление и ЭКГ"
AP_MON = "Вес и признаки метаболического синдрома"
def ad(id_, name, cls, start, lo, hi, items, *, ptsd=None, red=(), mon=(), withdrawal=None, overdose=False, note=None):
"""Antidepressant: doses from КР депрессия табл. 2, side effects from табл. 3."""
d = {
"id": id_,
"name_ru": name,
"group": "Антидепрессант",
"class": cls,
"indications": {
"depression": {
"start_mg": start,
"min_mg": lo,
"max_mg": hi,
"off_label": False,
"expected_response": "3–4 недели",
"course": "После улучшения лечение продолжают в той же дозе ещё 4–6 месяцев",
"source": f"{DEP}, прил. А3 табл. 2; разд. 3.1",
}
},
"diary_items": [SE[i] for i in items],
"diary_items_source": f"{DEP}, прил. А3 табл. 3 (++/+++)",
"red_flags": [RF_EARLY, *red],
"monitoring": list(mon),
"withdrawal_risk": withdrawal,
"overdose_danger": overdose,
"max_course_days": None,
}
if ptsd:
d["indications"]["ptsd"] = ptsd
if note:
d["note"] = note
return d
def ptsd_ind(lo, hi, off_label, start=None):
return {
"start_mg": start,
"min_mg": lo,
"max_mg": hi,
"off_label": off_label,
"expected_response": "4–6 недель",
"course": "Обычно 6–12 месяцев после стабилизации",
"source": f"{PTSD}, разд. 3.2; прил. А3",
}
def other(
id_, name, cls, lo, hi, off_label, items, *, red=(), mon=(), withdrawal=None, course_days=None, src, note=None
):
"""Non-antidepressant used for PTSD: doses from КР ПТСР прил. А3 / разд. 3.2, effects named in the text."""
ind = ptsd_ind(lo, hi, off_label)
ind["expected_response"] = None
ind["course"] = None
d = {
"id": id_,
"name_ru": name,
"group": next(g for key, g in GROUPS.items() if key in cls),
"class": cls,
"indications": {"ptsd": ind},
"diary_items": [SE[i] for i in items],
"diary_items_source": src,
"red_flags": list(red),
"monitoring": list(mon),
"withdrawal_risk": withdrawal,
"overdose_danger": False,
"max_course_days": course_days,
}
if note:
d["note"] = note
return d
# Patient-facing drug group by the КР class wording.
GROUPS = {
"бензодиазепин": "Анксиолитик (транквилизатор)",
"Анксиолитик": "Анксиолитик",
"Антипсихотическое": "Антипсихотик",
"Противоэпилептический": "Противоэпилептический препарат",
}
SSRI = ["nausea", "insomnia", "sexual", "headache"] # табл. 3 + головная боль из разд. 3.1 (СИОЗС)
BENZO = ["sedation", "dizziness", "coordination", "memory"] # КР ПТСР 3.2: седация, головокружение, координация…
BENZO_SRC = f"{PTSD}, разд. 3.2 (комментарии о производных бензодиазепина)"
AP_SRC = f"{PTSD}, разд. 3.2: акатизия, экстрапирамидные расстройства, метаболический синдром"
DRUGS = [
ad(
"paroxetine",
"Пароксетин",
"СИОЗС",
20,
20,
50,
SSRI,
ptsd=ptsd_ind(20, 50, False),
red=[RF_BLEEDING],
withdrawal="high",
),
ad("sertraline", "Сертралин", "СИОЗС", 50, 50, 200, SSRI, ptsd=ptsd_ind(25, 200, False), red=[RF_BLEEDING]),
ad(
"fluvoxamine",
"Флувоксамин",
"СИОЗС",
50,
100,
300,
SSRI,
red=[RF_BLEEDING],
note="Стартовая доза по табл. 2: 50–100 мг",
),
ad(
"fluoxetine",
"Флуоксетин",
"СИОЗС",
20,
20,
80,
SSRI,
ptsd=ptsd_ind(20, 40, True),
red=[RF_BLEEDING],
withdrawal="low",
),
ad("citalopram", "Циталопрам", "СИОЗС", 20, 20, 40, SSRI, red=[RF_BLEEDING]),
ad("escitalopram", "Эсциталопрам", "СИОЗС", 10, 10, 20, SSRI, red=[RF_BLEEDING]),
ad(
"venlafaxine",
"Венлафаксин",
"СИОЗСН",
75,
75,
375,
["nausea", "insomnia", "sexual"],
ptsd=ptsd_ind(75, 225, True),
mon=["Артериальное давление"],
withdrawal="high",
note="Депрессия: >225 мг/сут — в стационарных условиях",
),
ad("duloxetine", "Дулоксетин", "СИОЗСН", 60, 60, 120, ["nausea", "insomnia"]),
ad(
"amitriptyline",
"Амитриптилин",
"ТЦА",
50,
150,
300,
["anticholinergic", "sedation", "orthostatic", "weight"],
ptsd=ptsd_ind(75, 150, True),
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Депрессия: старт 50–75 мг; >150 мг/сут — в стационарных условиях",
),
ad(
"imipramine",
"Имипрамин",
"ТЦА",
25,
150,
300,
["anticholinergic", "insomnia", "orthostatic", "weight"],
ptsd=ptsd_ind(75, 250, True),
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Депрессия: старт 25–75 мг; >200 мг/сут — в стационарных условиях",
),
ad(
"clomipramine",
"Кломипрамин",
"ТЦА",
50,
100,
250,
["anticholinergic", "sexual", "orthostatic", "weight"],
mon=[TCA_MON],
withdrawal="high",
overdose=True,
note="Старт 50–75 мг; летальность при передозировке средняя",
),
ad(
"pipofezine",
"Пипофезин",
"ТЦА (АТХ — другие антидепрессанты)",
25,
150,
500,
[],
note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 25–50 мг",
),
ad("mianserin", "Миансерин", "НаССА", 30, 60, 90, ["sedation"], mon=["Формула крови (по КР — изменения редко)"]),
ad(
"mirtazapine",
"Миртазапин",
"НаССА",
15,
15,
45,
["sedation", "weight"],
ptsd=ptsd_ind(30, 60, True),
note="Старт 15–30 мг",
),
ad(
"trazodone",
"Тразодон",
"Специфический серотонинергический АД",
100,
150,
600,
["sedation", "sexual"],
red=[RF_PRIAPISM],
note=">450 мг/сут — в стационарных условиях",
),
ad(
"pirlindole",
"Пирлиндол",
"Ингибитор МАО-А",
50,
150,
400,
[],
note="В табл. 3 КР не описан — побочные эффекты уточнять у врача; старт 50–75 мг",
),
ad(
"agomelatine",
"Агомелатин",
"Мелатонинергический АД",
25,
25,
50,
[],
red=[RF_JAUNDICE],
mon=["Билирубин, АСТ, АЛТ, щелочная фосфатаза — в начале лечения и при повышении дозы"],
),
ad("vortioxetine", "Вортиоксетин", "Серотонинергический модулятор", 10, 10, 20, ["nausea", "insomnia"]),
# КР ПТСР: анксиолитики, антипсихотические средства, противоэпилептические препараты
other(
"diazepam",
"Диазепам",
"Производное бензодиазепина",
5,
15,
False,
BENZO,
withdrawal="high",
course_days=28,
src=BENZO_SRC,
note="Курс не более 4 недель, в тяжёлых случаях до 8–12 недель",
),
other(
"lorazepam",
"Лоразепам",
"Производное бензодиазепина",
2,
10,
False,
BENZO,
withdrawal="high",
course_days=21,
src=BENZO_SRC,
note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)",
),
other(
"clonazepam",
"Клоназепам",
"Производное бензодиазепина",
1,
4,
False,
BENZO,
withdrawal="high",
course_days=21,
src=BENZO_SRC,
note="Дозы — из текста разд. 3.2 (в табл. А3 не приведён)",
),
other(
"alprazolam",
"Алпразолам",
"Производное бензодиазепина",
0.25,
4.5,
False,
BENZO,
withdrawal="high",
course_days=90,
src=BENZO_SRC,
note="Курс до 3 месяцев; отмена в течение 2–6 недель",
),
other(
"tofisopam",
"Тофизопам",
"Производное бензодиазепина (дневной транквилизатор)",
50,
300,
False,
[],
course_days=84,
src=f"{PTSD}, разд. 3.2",
note="По КР не вызывает физической зависимости и седации; курс 4–12 недель",
),
other(
"etifoxine",
"Этифоксин",
"Анксиолитик",
150,
200,
False,
["sedation", "skin"],
red=[RF_JAUNDICE, RF_UTERINE],
course_days=42,
src=f"{PTSD}, разд. 3.2",
note="Курс от нескольких дней до 4–6 недель",
),
other(
"hydroxyzine",
"Гидроксизин",
"Анксиолитик",
25,
100,
True,
["sedation"],
course_days=56,
src=f"{PTSD}, разд. 3.2",
note="Курс обычно 4–8 недель",
),
other(
"buspirone",
"Буспирон",
"Анксиолитик",
20,
30,
False,
[],
src=f"{PTSD}, разд. 3.2",
note="Эффект с 7–14 дня, максимум к 4-й неделе",
),
other(
"alimemazine",
"Алимемазин",
"Антипсихотическое средство",
15,
80,
False,
[],
mon=[AP_MON],
src=f"{PTSD}, разд. 3.2",
note="По КР побочные эффекты редки и слабо выражены",
),
other(
"risperidone",
"Рисперидон",
"Антипсихотическое средство",
0.5,
8,
True,
["akathisia", "eps", "weight"],
mon=[AP_MON],
src=AP_SRC,
),
other(
"quetiapine",
"Кветиапин",
"Антипсихотическое средство",
50,
300,
True,
["akathisia", "eps", "weight"],
mon=[AP_MON],
src=AP_SRC,
),
other(
"olanzapine",
"Оланзапин",
"Антипсихотическое средство",
5,
20,
True,
["weight", "sedation"],
mon=[AP_MON],
src=f"{DEP}, разд. 3.3 (увеличение веса, седация); {PTSD}, разд. 3.2",
),
other(
"tiapride", "Тиаприд", "Антипсихотическое средство", 200, 300, False, [], mon=[AP_MON], src=f"{PTSD}, разд. 3.2"
),
other(
"chlorprothixene",
"Хлорпротиксен",
"Антипсихотическое средство",
25,
150,
True,
[],
mon=[AP_MON],
src=f"{PTSD}, разд. 3.2",
note="Расхождение в КР ПТСР: в тексте 15–100 мг, в табл. А3 — 25–150 мг",
),
other(
"lamotrigine",
"Ламотриджин",
"Противоэпилептический препарат",
25,
500,
True,
[],
red=[RF_RASH],
src=f"{PTSD}, разд. 3.2",
note="Частые кожные высыпания вплоть до токсического эпидермального некролиза",
),
other("topiramate", "Топирамат", "Противоэпилептический препарат", 200, 400, True, [], src=f"{PTSD}, разд. 3.2"),
]
TEXTS = {
"expect_effect_depression": (
"Лекарства от депрессии действуют не сразу. Первые изменения иногда заметны уже через 1–2 недели, "
"а эффект врач оценивает через 3–4 недели. Принимайте препарат по схеме, даже если пока не чувствуете разницы."
),
"expect_effect_ptsd": (
"Эффект препарата при ПТСР развивается постепенно — его оценивают через 4–6 недель. В первую неделю тревога "
"может временно усилиться: это известная реакция, сообщите о ней врачу. Не прекращайте приём самостоятельно."
),
"dont_stop_alone": (
"Не прекращайте приём самостоятельно: резкая отмена может ухудшить самочувствие, а лечение — потерять эффект. "
"Если препарат мешает жить или вы в нём сомневаетесь, обсудите это с врачом — он может изменить дозу "
"или подобрать другой."
),
"why_continue_depression": (
"Даже когда становится лучше, лечение продолжают ещё несколько месяцев в той же дозе: так ниже риск, "
"что депрессия вернётся."
),
"withdrawal_missed": (
"Вы пропустили приём два дня подряд. При резком прекращении этого препарата бывают неприятные ощущения: "
"головокружение, раздражительность, нарушения сна, состояние как при гриппе. Вернитесь к обычной схеме "
"и сообщите врачу, если стало хуже."
),
"ran_out": (
"Лекарство закончилось? Не откладывайте — попросите у врача новый рецепт: "
"перерыв в приёме может ухудшить самочувствие."
),
"red_flag": "Этот симптом нужно обсудить с врачом сегодня. Если состояние тяжёлое — вызывайте скорую: 103 или 112.",
"side_effects_general": (
"Спасибо, что сообщили. Побочные эффекты часто ослабевают в первые недели. Если они мешают жить — это повод "
"обсудить с врачом дозу или замену препарата. Не меняйте схему сами."
),
"instruction_fallback": "Уточните у врача или в инструкции к препарату.",
}
RULES = [
{"id": "M1", "params": {"missed_days_in_row": 2}, "status": "черновик"},
{"id": "M2", "params": {}, "status": "черновик"},
{"id": "M3", "params": {"ran_out_days_in_row": 2}, "status": "черновик"},
{"id": "M4", "params": {"default_max_course_days": 21}, "status": "КР ПТСР 3.2"},
{
"id": "M5",
"params": {"check_day": 28, "baseline_lookback_days": 14, "phq9_min_drop_pct": 50, "pcl5_min_drop": 10},
"status": "черновик",
},
{"id": "M6", "params": {}, "status": "КР депрессия 3.1.3"},
{"id": "M7", "params": {}, "status": "черновик"},
{"id": "M8", "params": {"fibser_burden_min": 5, "side_effect_misses_in_7_days": 2}, "status": "черновик"},
{"id": "M9", "params": {}, "status": "в сводке врачу"},
{"id": "M10", "params": {}, "status": "приложения А3 обеих КР"},
]
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
doc = {
"version": "draft-2026-10-04",
"status": "черновик: собран по КР Минздрава, тексты и параметры вычитывает психиатр",
"sources": [
{
"id": "kr_depression_2024",
"title": "Депрессивный эпизод, рекуррентное депрессивное расстройство",
"year": 2024,
"file": "docs/source/rag/kr_depression.pdf",
},
{
"id": "kr_ptsd_2023",
"title": "Посттравматическое стрессовое расстройство",
"year": 2023,
"kr_id": 753,
"file": "docs/source/rag/kr_ptsd.pdf",
},
],
"drugs": DRUGS,
"texts": TEXTS,
"rules": RULES,
}
OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{OUT}: {len(DRUGS)} drugs")
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
"""Test-wide isolation: a developer's local `.env` (e.g. OPENROUTER_API_KEY) must never make tests call a real
LLM provider — that costs money and makes results depend on the network. Agent tests pass explicit
providers/settings; everything else runs with the chat agent off."""
import pytest
from app.config import get_settings
@pytest.fixture(autouse=True)
def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CHAT_AGENT", "off")
monkeypatch.setenv("OPENROUTER_API_KEY", "")
monkeypatch.setenv("LLM_PROVIDER", "mock")
get_settings.cache_clear()
yield
get_settings.cache_clear()
+103
View File
@@ -0,0 +1,103 @@
"""MAX endpoints authenticate before reading bindings or accepting events."""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock
from urllib.parse import parse_qs, urlsplit
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.exc import OperationalError
from app.config import get_settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.integrations import max_store
from app.main import app
BASE = "/api/v1/me/notifications/max"
HOOK = "/api/v1/webhooks/max"
TOKEN = "synthetic-patient"
AUTH = {"Authorization": f"Bearer {TOKEN}"}
SECRET = "test-secret-" * 4
@pytest.fixture
async def client(monkeypatch):
for key, value in {
"MAX_ENABLED": "true",
"MAX_BOT_TOKEN": "fake-token",
"MAX_BOT_USERNAME": "test_bot",
"MAX_WEBHOOK_SECRET": SECRET,
"MAX_WEBHOOK_URL": "https://api.example.org" + HOOK,
"MAX_SITE_URL": "https://example.org",
}.items():
monkeypatch.setenv(key, value)
get_settings.cache_clear()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="")
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
get_settings.cache_clear()
@pytest.mark.parametrize("auth", [{}, {"Authorization": "Bearer unknown"}, {"Authorization": "Basic test"}])
async def test_auth_required(client, auth):
assert (await client.get(BASE, headers=auth)).status_code == 401
assert (await client.post(BASE + "/link", headers=auth, json={"consent": True})).status_code == 401
assert (await client.delete(BASE, headers=auth)).status_code == 401
async def test_link_requires_consent_and_hides_patient_credential(client):
for body in ({}, {"consent": False}, {"consent": True, "user_id": 100}):
assert (await client.post(BASE + "/link", headers=AUTH, json=body)).status_code == 422
response = await client.post(BASE + "/link", headers=AUTH, json={"consent": True})
assert response.status_code == 200
assert TOKEN not in response.text
url = urlsplit(response.json()["url"])
assert url.netloc == "max.ru" and url.path == "/test_bot"
code = parse_qs(url.query)["start"][0]
async with session.get_identity_session() as s:
assert await max_store.bind(s, code, 100, clock.now()) == TOKEN
assert (await client.get(BASE, headers=AUTH)).json()["connected"] is True
assert (await client.delete(BASE, headers=AUTH)).json()["connected"] is False
async def test_revoke_consent_prevents_linking(client):
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": False, "adult": True}, now=clock.now()
)
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 403
async def test_disabled_service_still_allows_disconnect(client, monkeypatch):
monkeypatch.setenv("MAX_ENABLED", "false")
get_settings.cache_clear()
assert (await client.get(BASE, headers=AUTH)).json()["available"] is False
assert (await client.post(BASE + "/link", headers=AUTH, json={"consent": True})).status_code == 503
assert (await client.post(HOOK, json={})).status_code == 503
assert (await client.delete(BASE, headers=AUTH)).status_code == 200
async def test_webhook_secret_checked_before_body_or_adapter(client):
assert (await client.post(HOOK, content="not-json")).status_code == 403
assert (await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": "wrong"}, json={})).status_code == 403
headers = {"X-Max-Bot-Api-Secret": SECRET}
assert (await client.post(HOOK, headers=headers, content="not-json")).status_code == 400
assert (await client.post(HOOK, headers=headers, json=[])).status_code == 422
assert (await client.post(HOOK, headers=headers, content=" " * 65537)).status_code == 413
async def test_webhook_storage_failure_is_retryable_without_identity_details(client, monkeypatch):
handler = AsyncMock(side_effect=OperationalError("private SQL", {"token": TOKEN}, Exception("private")))
monkeypatch.setitem(sys.modules, "app.channels.max_bot", SimpleNamespace(handle_update=handler))
response = await client.post(HOOK, headers={"X-Max-Bot-Api-Secret": SECRET}, json={})
assert response.status_code == 503 and TOKEN not in response.text and "private" not in response.text
+222
View File
@@ -0,0 +1,222 @@
"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
from collections.abc import AsyncIterator
from datetime import datetime, time, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import fixtures
from app.core import clock
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def _patient_with_code(code: str) -> None:
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Синтетический П.",
snils="000-000-000 00",
clinic_id="clinic-1",
doctor_id="doc-1",
now=clock.now(),
)
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now())
async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()
second = (await client.post("/api/v1/auth/anonymous")).json()
assert first["mode"] == "anonymous" and first["display_name"] is None
assert len(first["token"]) == 32 and first["token"] != second["token"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s:
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only.
profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and not profile.doctor_id
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
buttons = [b["data"] for r in replies for row in r["buttons"] for b in row]
assert "consent:yes" in buttons and "consent:no" in buttons
async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None:
await _patient_with_code("DEMO-0042")
ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "})
assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1"
again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"})
assert again.status_code == 401
async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401
assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422
async def test_emias_demo_persona(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0]
res = await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, persona.token)).doctor_id == fixtures.DOCTOR_ID
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
assert missing.status_code == 404
async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
now = clock.now()
today = now.date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5))
rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]})
s.add(rx)
await s.flush()
morning = datetime.combine(today, time(9, 0), now.tzinfo)
s.add_all(
[
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"),
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)),
]
)
await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6)
body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()
assert body["has_doctor"] and body["checkin_done"]
assert body["next_visit"] == (today + timedelta(days=5)).isoformat()
assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")]
week = body["prescriptions"][0]["week"]
assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending"
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is False and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
assert "112" in phones
async def test_today_links_prescription_to_reference_and_therapy_day(client: AsyncClient) -> None:
today = clock.now().date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", modules=["veteran"])
s.add(Prescription(token=TOKEN, drug="Сертралин", dose="50 мг", schedule={}, start_date=today - timedelta(13)))
rx = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["indication"] == "ptsd" and rx["therapy_day"] == 14
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
async def test_questionnaires_are_off_without_release_or_demo_flag(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("INTAKE_DEMO", raising=False)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1")
q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"]
assert q == {"enabled": False, "intake": "none", "diary_done": False, "diary_next": "morning"}
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "1")
persona = fixtures.DEMO_PERSONAS[0].token
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{persona}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
await press()
await press("consent:yes")
buttons = await press("age:adult")
# Stage 1 starts with PHQ-2, in the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active"
async def test_instrument_catalog_describes_intake_items(client: AsyncClient) -> None:
body = (await client.get("/api/v1/instruments/catalog")).json()
phq = body["items"]["phq9_3"]
assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"]
assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety"
assert body["items"]["sleep_hours"]["numeric"] and body["items"]["alcohol_portions"]["numeric"]
assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary"
assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale"
drinks = body["standard_drinks"]
assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"])
async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClient) -> None:
body = (await client.get("/api/v1/meds/reference")).json()
assert "rules" not in body and body["texts"]["dont_stop_alone"]
drugs = {d["id"]: d for d in body["drugs"]}
sertraline = drugs["sertraline"]
assert sertraline["indications"]["depression"]["max_mg"] == 200
assert (
sertraline["indications"]["ptsd"]["min_mg"] == 25 and "КР ПТСР" in sertraline["indications"]["ptsd"]["source"]
)
assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
# Stage 1 is the same for everyone: it does not wait for a module (it sets them itself).
monkeypatch.setenv("INTAKE_DEMO", "1")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press()
await press("consent:yes")
assert any(":phq9_1:" in b for b in await press("age:adult"))
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
+47
View File
@@ -0,0 +1,47 @@
"""MAX stays off without credentials; enabled deployments fail closed on incomplete settings."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def configured(**overrides):
fields = {
"max_enabled": True,
"max_bot_token": "test-only-token",
"max_bot_username": "test_bot",
"max_webhook_secret": "test-only-secret-" * 3,
"max_webhook_url": "https://api.example.org/api/v1/webhooks/max",
"max_site_url": "https://patient.example.org/#/today",
}
return Settings(_env_file=None, **(fields | overrides))
def test_max_disabled_by_default():
assert not Settings(_env_file=None).max_enabled
def test_max_secrets_are_redacted():
settings = configured()
assert "test-only-token" not in repr(settings)
assert "test-only-secret" not in repr(settings)
@pytest.mark.parametrize(
"overrides",
[
{"max_bot_token": ""},
{"max_bot_username": "@test_bot"},
{"max_webhook_secret": "short"},
{"max_site_url": "http://patient.example.org"},
{"max_webhook_url": "http://api.example.org"},
{"max_webhook_url": "https://api.example.org:8443/hook"},
{"max_webhook_url": "https://user:password@api.example.org/hook"},
{"max_webhook_url": "https://api.example.org/hook#fragment"},
{"max_site_url": "https://patient.example.org?token=credential"},
],
)
def test_max_rejects_incomplete_or_unsafe_settings(overrides):
with pytest.raises(ValidationError):
configured(**overrides)
+9 -1
View File
@@ -53,7 +53,15 @@ async def _tables(engine_fn: Callable[[], AsyncEngine]) -> set[str]:
async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None: async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
identity_tables = await _tables(session.identity_engine) identity_tables = await _tables(session.identity_engine)
clinical_tables = await _tables(session.clinical_engine) clinical_tables = await _tables(session.clinical_engine)
assert identity_tables == {"patients", "doctors", "connect_codes"} assert identity_tables == {
"patients",
"doctors",
"connect_codes",
"max_bindings",
"max_link_codes",
"max_webhook_receipts",
"max_deliveries",
}
assert { assert {
"patient_profiles", "patient_profiles",
"item_responses", "item_responses",
+125 -58
View File
@@ -94,6 +94,17 @@ async def complete_intake(e, choices=None):
raise AssertionError("intake did not terminate") raise AssertionError("intake did not terminate")
async def to_cssrs(e, choices=None):
"""Stage 1 up to the C-SSRS screener, which comes after PHQ-2, the trauma filter and RUS-AUDIT-S."""
replies = await press(e, "intake:start")
for _ in range(20):
button = answer_button(replies, {"trauma": 1, **(choices or {})})
if ":cssrs_" in button:
return replies
replies = await press(e, button)
raise AssertionError("C-SSRS not reached")
async def test_minimal_intake_raw_data_not_imputed_and_restart(released): async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
(row,) = await rows() (row,) = await rows()
@@ -111,13 +122,13 @@ async def test_pause_restart_and_duplicate_callbacks(released):
old = answer_button(replies) old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old)) await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows() (row,) = await rows()
assert row.answers == {"cssrs_q1": 0} assert row.answers == {"phq9_1": 0}
await press(released, "intake:pause") await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks())) restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume") replies = await press(restarted, "intake:resume")
assert "cssrs_q2" in answer_button(replies) assert "phq9_2" in answer_button(replies)
await press(restarted, old) await press(restarted, old)
assert (await rows())[0].answers == {"cssrs_q1": 0} assert (await rows())[0].answers == {"phq9_1": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@@ -127,6 +138,7 @@ async def test_immediate_stop_preserves_partial_answers(released, signal):
if signal == "help": if signal == "help":
replies = await press(released, "intake:help") replies = await press(released, "intake:help")
elif signal == "cssrs": elif signal == "cssrs":
replies = await to_cssrs(released)
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0})) replies = await press(released, answer_button(replies, {item: 0}))
else: else:
@@ -169,12 +181,13 @@ async def test_alcohol_conflict_keeps_positive_short_answer(released):
async def test_low_risk_shows_safety_plan_but_moderate_stops(released): async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
replies = await press(released, "intake:start") replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
replies = await press(released, answer_button(replies)) replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies)) replies = await press(released, answer_button(replies))
assert any(r.kind == "safety_plan" for r in replies) assert any(r.kind == "safety_plan" for r in replies)
assert "phq9_1" in answer_button(replies) # The screener ends stage 1; the context questions follow (sex is known from the profile).
assert ":tbi:" in answer_button(replies)
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch): async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
@@ -210,9 +223,10 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
monkeypatch.setattr(integrations, "outbox", Failing()) monkeypatch.setattr(integrations, "outbox", Failing())
await jobs.run_until_now() await jobs.run_until_now()
tasks = await rows(QuestionnaireTask) tasks = await rows(QuestionnaireTask)
assert len(tasks) == 2 # 20:00 Moscow: both parts of the diary are due; no risk monitoring → no daily C-SSRS.
risk = next(t for t in tasks if t.kind == "cssrs_since_contact") assert {t.kind for t in tasks} == {"diary_morning", "diary_evening"}
assert risk.delivery and risk.sent_at is None morning = next(t for t in tasks if t.kind == "diary_morning")
assert morning.delivery and morning.sent_at is None
outbox = MemoryOutbox() outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox) monkeypatch.setattr(integrations, "outbox", outbox)
await jobs.run_until_now() await jobs.run_until_now()
@@ -222,17 +236,29 @@ async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeyp
assert len(await rows()) == 2 assert len(await rows()) == 2
async def test_missing_since_contact_is_manual_review_not_recent(released): async def test_since_contact_risk_check_opens_the_evening_diary(released):
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW) task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
task_id = task.id task_id = task.id
replies = await released.handle( replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
) )
(row,) = await rows() assert any("последнего ответа" in r.text for r in replies)
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers for _ in range(5):
assert row.details["stop_reason"] == "cssrs_since_contact_required" button = answer_button(replies)
assert not answer_button(replies) if ":cssrs_" not in (button or ""):
break
replies = await press(released, button)
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
assert risk.status == "complete" and risk.answers == {
"cssrs_q1": 0,
"cssrs_q2": 0,
"cssrs_q6_lifetime": 0,
"cssrs_q6_recent": 0,
}
assert risk.details["answer_metadata"]["cssrs_q6_recent"]["derived"] is True
assert ":mood:" in answer_button(replies)
assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
async def test_disabled_or_no_adult_confirmation_cannot_start(released): async def test_disabled_or_no_adult_confirmation_cannot_start(released):
@@ -256,6 +282,20 @@ async def review_risk_record(e):
) )
async def run_diary(e, replies, choices=None, numbers=("6,5", "2")):
"""Answers the open diary questions; numeric items take `numbers` in turn."""
numbers = list(numbers)
for _ in range(30):
button = answer_button(replies, choices)
if button:
replies = await press(e, button)
elif any("часов" in r.text or "порци" in r.text for r in replies) and numbers:
replies = await e.handle(TOKEN, Event(type="text", text=numbers.pop(0)))
else:
return replies
raise AssertionError("diary did not terminate")
async def test_doctor_review_is_audited_and_unblocks_diary(released): async def test_doctor_review_is_audited_and_unblocks_diary(released):
from fastapi import HTTPException from fastapi import HTTPException
@@ -263,8 +303,14 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
from app.db.audit import AuditLog from app.db.audit import AuditLog
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await jobs.run_until_now() async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
replies = await run_diary(released, await press(released, "menu:checkin"))
# Evening: the risk check comes first; thoughts of a method (q3) are a moderate risk → stop for a specialist.
replies = await press(released, "menu:checkin")
replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0})
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
assert risk.status == "review_required"
with pytest.raises(HTTPException) as exc: with pytest.raises(HTTPException) as exc:
await review( await review(
TOKEN, TOKEN,
@@ -277,53 +323,43 @@ async def test_doctor_review_is_audited_and_unblocks_diary(released):
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required") assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
await review_risk_record(released) await review_risk_record(released)
await jobs.run_until_now() await jobs.run_until_now()
assert any(r.kind == "diary" and r.status == "active" for r in await rows()) assert any(r.kind == "diary_evening" and r.status == "active" for r in await rows())
await history(TOKEN, "synthetic-doctor") await history(TOKEN, "synthetic-doctor")
audits = await rows(AuditLog) audits = await rows(AuditLog)
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"} assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
async def test_diary_numeric_skip_and_not_prescribed(released): async def test_diary_morning_then_evening_with_the_ptsd_module(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin") replies = await press(released, "menu:checkin")
for _ in range(30): assert any("часов" in r.text for r in replies)
q = next( invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")), assert any("число часов" in r.text for r in invalid)
None, replies = await run_diary(released, invalid, {"sleep_quality": 3})
) assert any("утро" in r.text for r in replies)
if q == "comment": morning = next(r for r in await rows() if r.kind == "diary_morning")
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip")) assert morning.status == "complete"
replies = await press(released, button) assert morning.answers == {"sleep_hours": 6.5, "sleep_quality": 3, "alcohol_portions": 2.0}
elif q: replies = await run_diary(released, await press(released, "menu:checkin"), {"mood": 7, "medication": 2})
replies = await press(released, answer_button(replies, {"medication": 3})) evening = next(r for r in await rows() if r.kind == "diary_evening")
elif any("часов" in r.text for r in replies): assert evening.status == "complete" and evening.answers["medication"] == "not_prescribed"
invalid = await released.handle(TOKEN, Event(type="text", text="nan")) assert "medication_reason" not in evening.answers
assert any("число часов" in r.text for r in invalid) assert {"dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18"} <= set(evening.answers)
replies = await released.handle(TOKEN, Event(type="text", text="6,5")) assert evening.details["interpretation"]["kind"] == "diary_evening"
else: replies = await press(released, "menu:checkin")
break assert any("уже заполнен" in r.text for r in replies)
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
assert diary.answers["comment"] is None
async def test_high_risk_diary_asks_safety_first_and_stops(released): async def test_depression_module_item9_stops_with_crisis(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
await press(released, "menu:checkin") monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await review_risk_record(released) await run_diary(released, await press(released, "menu:checkin"))
replies = await press(released, "menu:checkin") replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1})
button = answer_button(replies)
assert "death_thoughts" in button
replies = await press(released, button)
assert any(r.kind == "crisis" for r in replies) assert any(r.kind == "crisis" for r in replies)
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line) evening = next(r for r in await rows() if r.kind == "diary_evening")
diary = next(r for r in await rows() if r.kind == "diary") assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted"
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
async def test_unknown_sex_goes_to_review_without_a_zone(released): async def test_unknown_sex_goes_to_review_without_a_zone(released):
@@ -344,7 +380,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released):
path.write_text(yaml.safe_dump(data, allow_unicode=True)) path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume") await press(released, "intake:resume")
(row,) = await rows() (row,) = await rows()
assert row.status == "review_required" and row.answers == {"cssrs_q1": 0} assert row.status == "review_required" and row.answers == {"phq9_1": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released): async def test_followup_delta_uses_comparable_complete_baseline(released):
@@ -377,20 +413,23 @@ async def test_stop_any_policy_interrupts_first_positive(released):
data = yaml.safe_load(path.read_text()) data = yaml.safe_load(path.read_text())
data["cssrs_positive_policy"] = "stop_any" data["cssrs_positive_policy"] = "stop_any"
path.write_text(yaml.safe_dump(data, allow_unicode=True)) path.write_text(yaml.safe_dump(data, allow_unicode=True))
replies = await press(released, "intake:start") replies = await to_cssrs(released)
replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
assert any(r.kind == "crisis" for r in replies) assert any(r.kind == "crisis" for r in replies)
assert (await rows())[0].answers == {"cssrs_q1": 1} assert (await rows())[0].answers["cssrs_q1"] == 1
async def test_evening_schedule_uses_moscow_date(released, monkeypatch): async def test_diary_schedule_uses_moscow_date(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59)) monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=4, minute=59))
await jobs.plan() await jobs.plan()
assert await rows(QuestionnaireTask) == [] assert await rows(QuestionnaireTask) == []
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0)) monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=5, minute=0))
await jobs.plan() await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"} assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning"}
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=17, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary_morning", "diary_evening"}
async def test_resumed_answer_keeps_original_period(released, monkeypatch): async def test_resumed_answer_keeps_original_period(released, monkeypatch):
@@ -401,7 +440,35 @@ async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:resume") replies = await press(released, "intake:resume")
await press(released, answer_button(replies)) await press(released, answer_button(replies))
(record,) = await rows() (record,) = await rows()
metadata = record.details["answer_metadata"]["cssrs_q2"] metadata = record.details["answer_metadata"]["phq9_2"]
assert metadata["period_start"] == "2026-09-03" assert metadata["period_start"] == "2026-09-19"
assert metadata["period_end"] == "2026-10-03" assert metadata["period_end"] == "2026-10-03"
assert metadata["answered_at"].startswith("2026-10-04") assert metadata["answered_at"].startswith("2026-10-04")
async def test_intake_result_is_shown_logged_and_sets_the_diary_modules(released):
from app.api.patient_web import my_results
choices = {f"phq9_{i}": 2 for i in range(1, 9)}
choices.update(trauma=1, tbi=1, psychotropics=1)
replies = await complete_intake(released, choices)
assert any("Предположение депрессии" in r.text for r in replies)
assert any("не диагноз" in r.text for r in replies)
(row,) = await rows()
interpretation = row.details["interpretation"]
assert interpretation["codes"] == ["depression_suspected"] and interpretation["modules"] == ["depression"]
async with session.get_clinical_session() as s:
# A doctor's modules (diagnosis) stay; the screening only adds to them.
assert (await repo.get_profile(s, TOKEN)).modules == ["veteran", "depression"]
results = await my_results(TOKEN)
assert results.conclusion.title == interpretation["title"]
assert [h.kind for h in results.history] == ["intake"] and results.history[0].status_title == "пройден"
async def test_without_a_doctor_the_screening_defines_the_modules(released):
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
replies = await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
assert any("не выявлено" in r.text for r in replies)
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).modules == []
+8
View File
@@ -202,6 +202,14 @@ async def test_side_effect_other_free_text(engine: Engine) -> None:
assert (await checkin()).side_effects == ["headache", "other"] assert (await checkin()).side_effects == ["headache", "other"]
async def test_side_effect_description_is_optional(engine: Engine) -> None:
reminder = await system(engine, "med_reminder")
r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
assert r[0].kind == "question" # the client shows an (optional) input for it
await press(engine, "text:skip")
assert (await checkin()).side_effects == ["other"] and (await get_state())[0] == "idle"
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None: async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
await repo.upsert_checkin( await repo.upsert_checkin(
+97 -29
View File
@@ -56,28 +56,51 @@ def test_medication_schedule_overlapping_and_delayed_completion():
) )
def test_diary_consecutive_days_and_baseline_do_not_include_bad_window(): def norm_and_week(norm: dict, week: dict, today=date(2026, 10, 30)):
today = date(2026, 10, 3) """14 days of personal norm starting 27 days ago, then the last 7 days with other values."""
history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)} start = today - timedelta(days=27)
for i in range(3): history = {start + timedelta(days=i): dict(norm) for i in range(14)}
history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"} history.update({today - timedelta(days=i): dict(week) for i in range(7)})
assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"} return history, today
history[today - timedelta(days=1)] = {}
assert diary_flags(history, today) == ()
history[today]["death_thoughts"] = 1
assert diary_flags(history, today) == ("suicide_assessment_now",)
def test_sleep_alternation_counts_but_threshold_is_strict(): def test_weekly_mood_against_personal_norm():
history, today = norm_and_week({"mood": 8}, {"mood": 5})
assert "phq9_due_mood" in diary_flags(history, today)
history, today = norm_and_week({"mood": 8}, {"mood": 6})
assert "phq9_due_mood" not in diary_flags(history, today)
# Inside the norm window there is no trend yet.
early = {date(2026, 10, 1) + timedelta(days=i): {"mood": 9 if i < 3 else 1} for i in range(10)}
assert "phq9_due_mood" not in diary_flags(early, date(2026, 10, 10))
def test_module_trends_bring_full_scales_forward():
ptsd = {"dpcl_1": 1, "dpcl_7": 1, "dpcl_10": 1, "dpcl_18": 1}
history, today = norm_and_week(ptsd, {k: 2 for k in ptsd})
assert "pcl5_due_ptsd" in diary_flags(history, today)
history, today = norm_and_week({"dphq_4": 0}, {"dphq_4": 1})
assert "phq9_due_depression" in diary_flags(history, today)
def test_sleep_alcohol_and_medication_flags():
today = date(2026, 10, 3) today = date(2026, 10, 3)
h = { nights = {today - timedelta(days=i): {"sleep_hours": 7, "sleep_quality": 0} for i in range(3)}
today: {"nightmares": 3}, nights[today - timedelta(days=1)] = {"sleep_hours": 3.5, "sleep_quality": 2}
today - timedelta(days=1): {"sleep_hours": 3.5}, assert "sleep_review" in diary_flags(nights, today)
today - timedelta(days=2): {"sleep_hours": 3.5}, nights[today - timedelta(days=1)]["sleep_hours"] = 4
} assert "sleep_review" not in diary_flags(nights, today)
assert "sleep_review" in diary_flags(h, today)
h[today - timedelta(days=1)]["sleep_hours"] = 4 drinks = {today: {"alcohol_portions": 6}, today - timedelta(days=2): {"alcohol_portions": 7}}
assert "sleep_review" not in diary_flags(h, today) assert set(diary_flags(drinks, today, medication_active=True)) >= {"alcohol_on_medication", "rus_audit_due_alcohol"}
assert "alcohol_on_medication" not in diary_flags(drinks, today)
missed = {today - timedelta(days=i): {"medication": "no"} for i in range(3)}
assert "medication_contact" in diary_flags(missed, today)
week = {today - timedelta(days=i): {"medication": "yes" if i % 3 else "no"} for i in range(7)}
assert "adherence_review" in diary_flags(week, today)
assert diary_flags({today: {"medication": "no", "medication_reason": "side_effects"}}, today) == (
"side_effects_review",
)
def test_total_threshold_and_zone_are_separate_decisions(): def test_total_threshold_and_zone_are_separate_decisions():
@@ -96,15 +119,60 @@ def test_total_threshold_and_zone_are_separate_decisions():
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2}) IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
def test_module_rotation_never_drops_suicide_question(): def test_diary_parts_and_module_rotation():
from app.core.intake_questions import diary_questions from app.core.intake_questions import diary_questions
first = { assert [q.id for q in diary_questions("morning", ["veteran", "depression"])] == [
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100) "sleep_hours",
} "sleep_quality",
second = { "alcohol_portions",
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101) ]
} evening = [q.id for q in diary_questions("evening", ["veteran", "depression"], day_index=0)]
assert "death_thoughts" in first & second assert evening == ["mood", "medication", "medication_reason", "dphq_1", "dphq_2", "dphq_3"] + [
assert "nightmares" in first and "nightmares" not in second "dpcl_1",
assert "burden" in first and "burden" not in second "dpcl_7",
"dpcl_10",
"dpcl_18",
]
assert [q.id for q in diary_questions("evening", ["depression"], day_index=5)][-3:] == [
"dphq_7",
"dphq_8",
"dphq_9",
]
assert [q.id for q in diary_questions("evening", [], day_index=1)] == ["mood", "medication", "medication_reason"]
def test_demo_policy_is_separate_and_marked(monkeypatch):
from app.config import get_settings
monkeypatch.setenv("INTAKE_DEMO", "1")
monkeypatch.setattr(get_settings(), "demo_clock", True)
demo = load_policy()
assert demo.approval and "DEMO" in demo.approval.reviewer and demo.cssrs_positive_policy == "graded"
monkeypatch.delenv("INTAKE_DEMO")
assert load_policy().approval is None
def test_full_scale_change_against_the_start_of_observation():
from app.core.interpretation import scale_interpretation
def phq(total, baseline, previous=None):
totals = {"phq9": {"complete": True, "total": total}}
return scale_interpretation(
"phq9", totals, baseline=baseline, previous=previous, policy=load_policy(), retriage_delta=5
)["codes"]
assert phq(4, 16) == ["minimal", "remission"]
assert phq(8, 16) == ["mild", "response"]
assert phq(11, 16) == ["moderate", "improved"]
assert phq(15, 16) == ["moderately_severe", "stable"]
assert phq(21, 10, previous=16) == ["severe", "worse"]
pcl = scale_interpretation(
"pcl5",
{"pcl5": {"complete": True, "total": 27}},
baseline=45,
previous=40,
policy=load_policy(),
retriage_delta=10,
)
assert pcl["codes"] == ["below", "response"]
+123
View File
@@ -0,0 +1,123 @@
"""Persistent, one-time linking and queue recovery; synthetic identities only."""
from datetime import timedelta
import pytest
from sqlalchemy import select
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ScaleResult
from app.db.max_notifications import MaxBinding, MaxDelivery, MaxLinkCode
from app.integrations import max_store as store
@pytest.fixture(autouse=True)
async def database():
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
yield
await session.configure()
async def test_codes_are_hashed_expiring_one_time_and_replaced():
now = clock.now()
async with session.get_identity_session() as s:
old, _ = await store.issue_code(s, "patient", now, 60)
code, expires = await store.issue_code(s, "patient", now, 60)
assert await store.code_token(s, old, now) is None
row = (await s.scalars(select(MaxLinkCode))).one()
assert row.digest != code and row.digest == store.digest(code)
assert await store.code_token(s, code, expires) is None
assert await store.bind(s, code, 100, now) == "patient"
assert await store.bind(s, code, 100, now) is None
async with session.get_identity_session() as s:
assert (await s.get(MaxBinding, "patient")).user_id == 100
async def test_active_binding_cannot_be_reassigned_in_either_direction():
now = clock.now()
async with session.get_identity_session() as s:
code, _ = await store.issue_code(s, "one", now, 60)
await store.bind(s, code, 100, now)
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) is None
assert await store.code_token(s, code, now) == "two"
code, _ = await store.issue_code(s, "one", now, 60)
assert await store.bind(s, code, 200, now) is None
await store.disconnect(s, "one")
assert await store.code_token(s, code, now) is None
code, _ = await store.issue_code(s, "two", now, 60)
assert await store.bind(s, code, 100, now) == "two"
async def test_disconnect_cancels_pending_delivery_and_prevents_stale_ack():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, token="one", kind="notification", now=now)
row = await store.claim(s, now)
async with session.get_identity_session() as s:
await store.disconnect(s, "one")
await store.finish(s, row, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "cancelled"
async def test_queue_survives_sessions_deduplicates_and_recovers_expired_lease():
now = clock.now()
async with session.get_identity_session() as s:
for _ in range(2):
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
async with session.get_identity_session() as s:
first = await store.claim(s, now)
assert first.attempts == 1
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(seconds=59)) is None
async with session.get_identity_session() as s:
second = await store.claim(s, now + timedelta(seconds=61))
assert second.id == first.id and second.lease != first.lease and second.attempts == 2
async with session.get_identity_session() as s:
await store.finish(s, first, state="sent", now=now)
async with session.get_identity_session() as s:
assert (await s.get(MaxDelivery, "job")).state == "sending"
await store.finish(s, second, state="sent", now=now)
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async def test_retry_deadline_and_attempt_limit():
now = clock.now()
async with session.get_identity_session() as s:
await store.enqueue(s, key="job", user_id=100, kind="help", now=now)
for attempt in range(store.MAX_ATTEMPTS):
when = now + timedelta(seconds=attempt * 120)
async with session.get_identity_session() as s:
row = await store.claim(s, when)
async with session.get_identity_session() as s:
await store.finish(s, row, state="queued", now=when, retry_seconds=120, error="http_429")
async with session.get_identity_session() as s:
assert await store.claim(s, when + timedelta(seconds=119)) is None
async with session.get_identity_session() as s:
assert await store.claim(s, now + timedelta(days=1)) is None
async with session.get_identity_session() as s:
row = await s.get(MaxDelivery, "job")
assert row.state == "failed" and row.attempts == store.MAX_ATTEMPTS
async def test_completion_scan_ignores_old_and_incomplete_tests_and_recovers_without_duplicates():
now = clock.now()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token="one", doctor_id="")
await repo.set_dialog_state(s, token="one", state="idle", data={"consent": True, "adult": True}, now=now)
for ts, complete in ((now - timedelta(days=1), True), (now, True), (now, False)):
s.add(ScaleResult(token="one", ts=ts, scale="phq9", total=0, severity="none", complete=complete))
async with session.get_identity_session() as s:
s.add(MaxBinding(token="one", user_id=100, enabled=True, connected_at=now))
for _ in range(2):
async with session.get_identity_session() as s:
await store.collect_completed(s, now)
async with session.get_identity_session() as s:
row = (await s.scalars(select(MaxDelivery))).one()
assert row.kind == "completed" and row.token == "one"
assert "phq9" not in row.id
+100
View File
@@ -169,3 +169,103 @@ def test_build_provider_modes():
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider) assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None
async def test_check_defaults_to_current_prescription(sertraline):
out = json.loads(await run_tool(ToolContext(token=ANDREY), "check_drug_for_patient", {"drug": "мои таблетки"}))
assert out["found"] and out["inn"] == "сертралин"
assert "лактоз" in out["intolerance_check"] and "нет" in out["intolerance_check"]
assert any("Алкоголь" in t for t in out["general_interactions"])
async def test_empty_final_answer_is_nudged(sertraline):
replies = iter(
[
{"role": "assistant", "content": None, "tool_calls": _CALLS},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": "Алкоголь во время лечения не рекомендуется. Обсудите с врачом."},
]
)
sent = []
def handler(request):
sent.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": next(replies)}]})
provider = OpenRouterProvider(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
ans = await agent.answer_chat(ANDREY, scrub("пиво можно?"), provider=provider, settings=_settings())
assert ans is not None and "Алкоголь" in ans.text
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt
async def test_region_block_string_error_degrades():
# Seen live from the RF server: 403 {"success": false, "error": "Access denied by security policy."}
client = httpx.AsyncClient(
transport=httpx.MockTransport(lambda r: httpx.Response(403, json={"success": False, "error": "Access denied"}))
)
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unexpected_provider_bug_degrades():
class Broken:
name = "broken"
async def chat(self, messages, tools):
raise AttributeError("boom")
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=Broken(), settings=_settings()) is None
# ------------------------------------------------------------------------------------------------- gigachat
class _FakeGiga:
"""Mimics gigachat.GigaChat.achat: first a function_call, then the final text."""
def __init__(self):
self.chats = []
async def achat(self, chat):
from types import SimpleNamespace as NS
self.chats.append(chat)
if len(self.chats) == 1:
fc = NS(name="check_drug_for_patient", arguments={"drug": "ибупрофен"})
msg = NS(content="", function_call=fc)
else:
msg = NS(
content="Ибупрофен вместе с сертралином повышает риск кровотечений. Обсудите с врачом.",
function_call=None,
)
return NS(choices=[NS(message=msg)], usage=NS(total_tokens=42))
async def test_gigachat_tool_loop(sertraline):
from app.llm.agent.providers import GigaChatToolProvider
fake = _FakeGiga()
s = Settings(_env_file=None, chat_agent="gigachat", gigachat_credentials="x", chat_agent_timeout_s=5.0)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен?"), provider=GigaChatToolProvider(s, client=fake), settings=s
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "gigachat"
second = fake.chats[1]
roles = [str(m.role.value if hasattr(m.role, "value") else m.role) for m in second.messages]
assert roles == ["system", "user", "assistant", "function"]
assert second.messages[2].function_call.name == "check_drug_for_patient"
assert second.messages[3].name == "check_drug_for_patient"
assert {f.name for f in second.functions} == {
"search_guidelines",
"get_patient_record",
"get_drug_info",
"check_drug_for_patient",
}
def test_build_provider_prefers_gigachat():
from app.llm.agent.providers import GigaChatToolProvider
s = Settings(_env_file=None, chat_agent="auto", gigachat_credentials="x", openrouter_api_key="y")
assert isinstance(agent.build_provider(s), GigaChatToolProvider)
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.vite/
+71
View File
@@ -0,0 +1,71 @@
# Веб-приложение пациента
Ещё один канал к тому же движку диалога, что и Telegram-бот (ADR-004): согласие, онбординг, шкалы, чек-ины и
кризисный скрипт приходят из `app/dialog` через `/api/v1/dialog/{token}/...`. Бот продолжает работать параллельно.
## Запуск
```bash
make up # или: uv run uvicorn app.main:app --port 8000
cd web && npm install
npm run dev # http://localhost:5173 — /api проксируется на :8000
```
Интерфейс открывается на **:5173**; на :8000 работает только API (`/docs`).
## Вход
| Способ | Что происходит | API |
|---|---|---|
| ЕМИАС (демо) | одноразовый код от врача → токен пациента, привязка к врачу; или синтетический демо-пациент | `POST /auth/emias`, `POST /auth/emias/demo` |
| Анонимно | случайный токен, в identity-БД ничего не пишется, врач не привязан | `POST /auth/anonymous` |
Реальная авторизация ЕМИАС/ЕСИА подключается как `IdentityProvider` (`app/integrations/emias_mock.py`) без
изменений во фронтенде.
## Структура (под перенос в мобильное приложение)
```
src/theme/tokens.css цвета, шрифт, отступы — единственный источник; три цвета = три направления сервиса
src/content/ru.ts все тексты интерфейса (тексты диалога — в config/texts/, их отдаёт движок)
src/api/ типы-зеркала контрактов бэкенда и клиент
src/session/store.ts хранение сессии за интерфейсом: localStorage → Capacitor Preferences/Keychain
src/dialog/ общий разговор с движком (DialogContext) и выбор вида экрана по ответу движка (step.ts)
src/screens/ экраны по docs/mock: вход A1–A3, фокус-экран /flow (A4–A6, B, D, E1–E3, E5, G2–G4),
«Сегодня» C1/C2, «Лекарства» E4, «Врач» F, «Профиль» H, «Сообщения» I1
```
### Как устроены экраны
Логика сценариев живёт только в движке диалога. Экран `/flow` берёт последний ответ движка и рисует его
как отдельный экран мокапа: вопрос анкеты с прогрессом «Вопрос n из N», шкала 0–10, свободный текст,
напоминание о лекарстве, согласие, кризисный экран. Новый сценарий в движке появится в вебе без правок фронтенда.
Данные для «Сегодня» и «Лекарства» — `GET /api/v1/me/{token}/today` (дозы на сегодня, отметки за неделю,
следующий визит, время по демо-часам).
### Анкеты, дневник и модуль «Лекарства»
- **Анкеты этапа 1** (C-SSRS, PHQ-2/PHQ-9, фильтр травмы, TSQ, PCL-5, RUS-AUDIT-S/RUS-AUDIT) и **дневник этапа 2** —
тот же фокус-экран. Пункт распознаётся по кнопкам `iq:…`; чип раздела, «Вопрос n из N» (для адаптивных форм —
без «из N»), период, «Продолжить позже», «Мне нужна помощь сейчас», пропуск необязательных пунктов, ввод часов сна,
шкалы 0–10 с подписями концов, таблица стандартных порций для п. 2 RUS-AUDIT (из утверждённого бланка, без
калькулятора). Метаданные — `GET /api/v1/instruments/catalog`.
- Анкеты включаются только в демо: `INTAKE_DEMO=1` (вместе с `DEMO_CLOCK`). Клинический замок в
`config/intake_runtime.yaml` не трогается. Пока нет подписанной политики маршрутизации и формы C-SSRS «с последнего
контакта», завершённый опрос и дневник уходят на оценку специалиста — UI показывает это отдельным экраном.
- **Карточка препарата** `/meds/:ref` — справочник `data/meds/meds_reference.json` (собирается
`scripts/build_meds_reference.py` по КР Минздрава: депрессия 2024, ПТСР 2023): когда ждать эффекта (с днём лечения),
сколько принимать, что может беспокоить, когда срочно к врачу, «не прекращать самостоятельно». Дозы не показываются.
Черновик — вычитывает психиатр. `GET /api/v1/meds/reference`.
- «Сообщить о побочном эффекте» доступно всегда (`med:side_effects:<доза или дата>`), карточка «День N лечения» на
«Сегодня» — в 1-й, 7-й и 14-й день.
Мобильная версия: mobile-first вёрстка, safe-area, hash-роутинг и относительные пути сборки — `dist/` оборачивается
Capacitor без изменений (`VITE_API_BASE` = абсолютный адрес API).
## Известные ограничения прототипа
- Токен пациента используется как учётные данные сессии; в проде — короткоживущие серверные сессии.
- Подбор кодов не ограничен по частоте.
- В демо нет сида кодов подключения: для показа используйте демо-пациента.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Сопровождение пациентов между визитами к врачу" />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>Сопровождение пациента</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1802
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ptsr-patient-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@fontsource/golos-text": "^5.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "~5.6.3",
"vite": "^5.4.10"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="9" fill="#285e4e"/><path d="M16 8v16M8 16h16" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 201 B

+57
View File
@@ -0,0 +1,57 @@
import type {
AuthResult,
Catalog,
CrisisResource,
DemoPersona,
DialogEvent,
Focus,
MedsReference,
Reply,
Results,
TodayPlan,
} from "./types";
// Web: same-origin /api/v1 (Vite proxy in dev). Mobile shell: set VITE_API_BASE to the absolute API URL at build.
const BASE: string = import.meta.env.VITE_API_BASE ?? "/api/v1";
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
} catch {
throw new ApiError(0, "network");
}
if (!res.ok) throw new ApiError(res.status, res.statusText);
return (await res.json()) as T;
}
const post = <T>(path: string, body?: unknown) =>
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
export const api = {
signInEmias: (code: string) => post<AuthResult>("/auth/emias", { code }),
signInEmiasDemo: (personaToken: string) => post<AuthResult>("/auth/emias/demo", { persona_token: personaToken }),
signInAnonymous: (focus: Focus) => post<AuthResult>("/auth/anonymous", { focus }),
demoPersonas: () => request<DemoPersona[]>("/patient/demo-personas"),
crisisResources: () => request<CrisisResource[]>("/crisis/resources"),
catalog: () => request<Catalog>("/instruments/catalog"),
medsReference: () => request<MedsReference>("/meds/reference"),
today: (token: string) => request<TodayPlan>(`/me/${encodeURIComponent(token)}/today`),
results: (token: string) => request<Results>(`/me/${encodeURIComponent(token)}/results`),
sendEvent: async (token: string, event: DialogEvent) =>
(await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies,
outbox: async (token: string) =>
(await request<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/outbox`)).replies,
};
+182
View File
@@ -0,0 +1,182 @@
// Mirrors of the backend contracts (app/dialog/contracts.py, app/api/schemas.py, app/api/patient_web.py).
// Keep in sync by hand: the frozen contracts change only through a [contract] PR.
export interface DialogButton {
label: string;
data: string;
}
export type ReplyKind = "normal" | "question" | "crisis" | "safety_plan" | "info";
export interface Reply {
text: string;
buttons: DialogButton[][];
kind: ReplyKind;
}
export type DialogEvent =
| { type: "start"; params?: Record<string, unknown> }
| { type: "text"; text: string }
| { type: "button"; button: string };
export type AuthMode = "emias" | "anonymous";
/** What an anonymous person suspects; the backend maps it to a clinical module. */
export type Focus = "depression" | "ptsd";
export interface AuthResult {
token: string;
mode: AuthMode;
display_name: string | null;
}
export interface CrisisResource {
name: string;
phone: string;
note: string;
}
export interface DemoPersona {
token: string;
display_name: string;
therapy_day: number;
scenario: string | null;
}
export type DoseStatus = "taken" | "missed" | "skipped" | "pending";
export interface TodayDose {
id: number;
drug: string;
dose: string;
time: string;
status: DoseStatus;
}
export type Indication = "depression" | "ptsd";
export interface TodayPrescription {
drug: string;
dose: string;
times: string[];
week: { day: string; status: "taken" | "missed" | "pending" | "none" }[];
ref_id: string | null;
indication: Indication | null;
start_date: string | null;
therapy_day: number | null;
}
export interface Questionnaires {
enabled: boolean;
intake: "none" | "active" | "paused" | "complete" | "review";
diary_done: boolean;
diary_next: "morning" | "evening" | null;
}
// app/api/patient_web.py: /me/{token}/results — preliminary conclusion of stage 1 and the questionnaire log.
export interface Conclusion {
day: string;
title: string;
lines: string[];
findings: { code: string; title: string }[];
modules: string[];
disclaimer: string;
}
export interface HistoryItem {
id: string;
kind: string;
kind_title: string;
status: string;
status_title: string;
day: string;
started_at: string;
completed_at: string | null;
title: string | null;
lines: string[];
}
export interface Results {
conclusion: Conclusion | null;
history: HistoryItem[];
}
export interface TodayPlan {
today: string;
now: string;
has_doctor: boolean;
checkin_done: boolean;
next_visit: string | null;
doses: TodayDose[];
prescriptions: TodayPrescription[];
questionnaires: Questionnaires;
}
// app/core/instrument_catalog.py
export type Section = "safety" | "mood" | "trauma" | "alcohol" | "about" | "diary" | "scale";
export interface FormInfo {
id: string;
title: string;
section: Section;
recall_period: string | null;
instruction: string | null;
}
export interface ItemInfo {
form: string;
index: number;
total: number;
adaptive: boolean;
optional: boolean;
numeric: boolean;
}
export interface Catalog {
forms: Record<string, FormInfo>;
items: Record<string, ItemInfo>;
standard_drinks: { title: string; header: string[]; rows: [string, string[]][]; note: string } | null;
}
// data/meds/meds_reference.json (draft from the Minzdrav guidelines)
export interface DrugIndication {
off_label: boolean;
expected_response: string | null;
course: string | null;
source: string;
}
export interface DrugRef {
id: string;
name_ru: string;
group: string;
class: string;
indications: Partial<Record<Indication, DrugIndication>>;
diary_items: string[];
diary_items_source: string;
red_flags: string[];
monitoring: string[];
withdrawal_risk: "high" | "low" | null;
overdose_danger: boolean;
max_course_days: number | null;
note?: string;
}
export interface MedsReference {
version: string;
status: string;
sources: { id: string; title: string; year: number }[];
drugs: DrugRef[];
texts: Record<
| "expect_effect_depression"
| "expect_effect_ptsd"
| "dont_stop_alone"
| "why_continue_depression"
| "withdrawal_missed"
| "ran_out"
| "red_flag"
| "side_effects_general"
| "instruction_fallback",
string
>;
}
+216
View File
@@ -0,0 +1,216 @@
import type { ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
import { HotlineCard, useHelp } from "./HelpSheet";
import { Icon, LogoMark, type IconName } from "./Icon";
/*
* Shared patient workspace, following the approved doctor dashboard.
* phone: content → «Экстренная помощь» block → bottom tab bar (signed in)
* desktop: white sidebar | workspace header | content + emergency panel
* bare: one centred column on every width (questionnaires)
*/
const TABS: { to: string; label: string; icon: IconName }[] = [
{ to: "/today", label: ru.nav.today, icon: "home" },
{ to: "/meds", label: ru.nav.meds, icon: "pill" },
{ to: "/doctor", label: ru.nav.doctor, icon: "doctor" },
{ to: "/profile", label: ru.nav.profile, icon: "user" },
];
/** Inline emergency block shown at the bottom of every phone screen (hidden on desktop: the rail has it). */
export function EmergencyBlock() {
const { resources, open } = useHelp();
return (
<section className="sosblock" aria-label={ru.help.block}>
<button type="button" className="sosblock__head" onClick={open}>
<span className="sosblock__icon">
<Icon name="phone" size={18} />
</span>
<span>{ru.help.block}</span>
<Icon name="chevronRight" size={20} />
</button>
<div className="sosblock__lines">
{resources.slice(0, 2).map((r) => (
<HotlineCard key={r.phone} r={r} size="sm" />
))}
</div>
</section>
);
}
function Sidebar({ signedIn }: { signedIn: boolean }) {
const { open } = useHelp();
const { unread } = useDialog();
return (
<nav className="sidebar" aria-label="Разделы">
<Link to={signedIn ? "/today" : "/"} className="sidebar__brand">
<LogoMark />
<span>
<span className="sidebar__name">{ru.product.name}</span>
<span className="sidebar__tag">{ru.product.tagline}</span>
</span>
</Link>
{signedIn ? (
<ul className="sidebar__list">
{TABS.map((t) => (
<li key={t.to}>
<NavLink to={t.to} className="sidebar__link">
<Icon name={t.icon} />
{t.label}
</NavLink>
</li>
))}
<li>
<NavLink to="/messages" className="sidebar__link">
<Icon name="message" />
{ru.nav.messages}
{unread > 0 && <span className="dot" aria-label="новое" />}
</NavLink>
</li>
</ul>
) : (
<ul className="sidebar__list">
<li>
<NavLink to="/" end className="sidebar__link">
<Icon name="home" />
{ru.nav.signIn}
</NavLink>
</li>
<li>
<button type="button" className="sidebar__link" onClick={open}>
<Icon name="question" />
{ru.nav.help}
</button>
</li>
</ul>
)}
<div className="sidebar__note">
<strong>{ru.product.supportTitle}</strong>
<span>{ru.product.supportText}</span>
</div>
</nav>
);
}
function BottomNav() {
return (
<nav className="tabbar" aria-label="Разделы">
{TABS.map((t) => (
<NavLink key={t.to} to={t.to} className="tabbar__item">
<Icon name={t.icon} />
<span>{t.label}</span>
</NavLink>
))}
</nav>
);
}
function Rail({ aside }: { aside?: ReactNode }) {
const { resources, open } = useHelp();
return (
<aside className="rail">
{aside}
<section className="railsos" aria-label={ru.help.block}>
<button type="button" className="railsos__head" onClick={open}>
<Icon name="phone" size={18} />
<span>{ru.help.block}</span>
</button>
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} size="sm" />
))}
</section>
</aside>
);
}
function WorkspaceHeader({ signedIn, bare = false }: { signedIn: boolean; bare?: boolean }) {
const { session } = useSession();
return (
<header className={`workspace${bare ? " workspace--bare" : ""}`}>
<div className="workspace__brand">
<LogoMark size={30} />
<span>{bare ? ru.product.name : signedIn ? ru.product.workspace : ru.nav.signIn}</span>
</div>
{signedIn && !bare ? (
<Link className="workspace__profile" to="/profile">
<span className="avatar"><Icon name="user" size={18} /></span>
<span>{session?.displayName || ru.nav.profile}</span>
</Link>
) : <span className="workspace__tag">{ru.product.tagline}</span>}
</header>
);
}
export function AppShell({
children,
signedIn = true,
aside,
focus = false,
sos = true,
bare = false,
}: {
children: ReactNode;
signedIn?: boolean;
aside?: ReactNode;
/** Focus screens (one question per screen) hide the tab bar on phones, like in the mocks. */
focus?: boolean;
/** The crisis screen shows the hotlines itself, so it drops the bottom block. */
sos?: boolean;
/** Questionnaires: no sidebar, rail or tab bar — one centred column, so nothing distracts from the answers. */
bare?: boolean;
}) {
if (bare)
return (
<div className="app app--bare">
<div className="app__main">
<WorkspaceHeader signedIn={signedIn} bare />
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
</div>
</div>
);
return (
<div className={`app${signedIn && !focus ? " app--tabs" : ""}`}>
<Sidebar signedIn={signedIn} />
<div className="app__main">
<WorkspaceHeader signedIn={signedIn} />
<main className="screen" id="main">
{children}
{sos && <EmergencyBlock />}
</main>
<Rail aside={aside} />
</div>
{signedIn && !focus && <BottomNav />}
</div>
);
}
export function Notice({ tone = "info", children }: { tone?: "info" | "warn" | "danger" | "ok"; children: ReactNode }) {
const icon: IconName = tone === "info" ? "info" : tone === "ok" ? "check" : tone === "danger" ? "x" : "alert";
return (
<div className={`notice notice--${tone}`} role="note">
<Icon name={icon} />
<div>{children}</div>
</div>
);
}
export function TopBar({ onBack, title, children }: { onBack?: () => void; title?: string; children?: ReactNode }) {
return (
<div className="topbar">
{onBack && (
<button type="button" className="icon-btn" onClick={onBack} aria-label={ru.common.back}>
<Icon name="arrowLeft" />
</button>
)}
{title && <span className="topbar__title">{title}</span>}
{children}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { api } from "../api/client";
import type { CrisisResource } from "../api/types";
import { ru } from "../content/ru";
import { Icon } from "./Icon";
// 112 is shown even when the API is unreachable: emergency help must never be empty (S2 «нет сети»).
const FALLBACK: CrisisResource[] = [{ name: ru.help.fallbackName, phone: "112", note: "" }];
export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
interface HelpApi {
resources: CrisisResource[];
open: () => void;
}
const Ctx = createContext<HelpApi>({ resources: FALLBACK, open: () => undefined });
export const useHelp = () => useContext(Ctx);
/** Crisis lines (from config/texts/crisis.yaml via the API) + the G1 «Экстренная помощь» sheet. */
export function HelpProvider({ children }: { children: ReactNode }) {
const [resources, setResources] = useState<CrisisResource[]>(FALLBACK);
const [isOpen, setOpen] = useState(false);
useEffect(() => {
api
.crisisResources()
.then((r) => r.length && setResources(r))
.catch(() => undefined);
}, []);
const open = useCallback(() => setOpen(true), []);
return (
<Ctx.Provider value={{ resources, open }}>
{children}
<HelpSheet open={isOpen} onClose={() => setOpen(false)} resources={resources} />
</Ctx.Provider>
);
}
export function HotlineCard({ r, size = "lg" }: { r: CrisisResource; size?: "lg" | "sm" }) {
const emergency = r.phone.replace(/\D/g, "").length <= 3;
return (
<a className={`hotline hotline--${size}`} href={telHref(r.phone)}>
<span className="hotline__icon">
<Icon name={emergency ? "phone" : "headset"} size={size === "lg" ? 26 : 20} />
</span>
<span className="hotline__text">
{emergency ? (
<>
<span className="hotline__phone">{r.phone}</span>
<span className="hotline__name">{r.name}</span>
</>
) : (
<>
<span className="hotline__name hotline__name--strong">{r.name}</span>
<span className="hotline__phone hotline__phone--sm">{r.phone}</span>
</>
)}
{r.note && size === "lg" && <span className="hotline__note">{r.note}</span>}
</span>
{size === "lg" && <Icon name="chevronRight" size={20} />}
</a>
);
}
function HelpSheet({ open, onClose, resources }: { open: boolean; onClose: () => void; resources: CrisisResource[] }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog
ref={ref}
className="sheet"
aria-labelledby="help-title"
onClose={onClose}
onClick={(e) => e.target === ref.current && onClose()}
>
<div className="sheet__body">
<div className="sheet__bar">
<button type="button" className="icon-btn" onClick={onClose} aria-label={ru.help.close}>
<Icon name="close" />
</button>
<h2 id="help-title" className="sheet__title">
{ru.help.title}
</h2>
</div>
<div className="sos">
<span className="sos__ring">
<span className="sos__btn">
<Icon name="phone" size={40} />
</span>
</span>
<p className="sos__lead">{ru.help.lead}</p>
</div>
<div className="hotlines">
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} />
))}
</div>
<p className="notice notice--info">
<Icon name="info" />
<span>{ru.help.alwaysNote}</span>
</p>
</div>
</dialog>
);
}
+170
View File
@@ -0,0 +1,170 @@
// Line icon set (24px grid, 1.8 stroke). Decorative by default: pass `label` to expose to screen readers.
const paths = {
home: <path d="M4 10.5L12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-6h-5v6H5a1 1 0 0 1-1-1v-8.5z" />,
pill: (
<>
<rect x="3" y="8.5" width="18" height="7" rx="3.5" transform="rotate(-45 12 12)" />
<path d="M9.5 9.5l5 5" />
</>
),
doctor: (
<>
<circle cx="12" cy="7.5" r="3.5" />
<path d="M5 20.5c0-3.9 3.1-6.5 7-6.5s7 2.6 7 6.5" />
<path d="M12 15.5v3m-1.5-1.5h3" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="4" />
<path d="M4.5 20.5c.8-3.7 3.8-6 7.5-6s6.7 2.3 7.5 6" />
</>
),
bell: (
<>
<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 2h-15L6 16.5z" />
<path d="M10 20.5a2 2 0 0 0 4 0" />
</>
),
chevronRight: <path d="M9.5 6l6 6-6 6" />,
chevronDown: <path d="M6 9.5l6 6 6-6" />,
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
x: <path d="M7 7l10 10M17 7L7 17" />,
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5m0-8.5v.5" />
</>
),
alert: (
<>
<path d="M12 3.5l9.5 16.5h-19L12 3.5z" />
<path d="M12 10v4.5m0 2.5v.5" />
</>
),
phone: (
<path d="M6.6 3.8h2.6l1.4 4-2 1.4a12 12 0 0 0 5.9 5.9l1.4-2 4 1.4v2.6c0 1-.8 1.8-1.8 1.8A15.8 15.8 0 0 1 4.8 5.6c0-1 .8-1.8 1.8-1.8z" />
),
headset: (
<>
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
<rect x="3" y="13.5" width="4" height="6" rx="1.5" />
<rect x="17" y="13.5" width="4" height="6" rx="1.5" />
</>
),
calendar: (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
<path d="M3.5 9.5h17M8 3v4m8-4v4M8 13h1m3 0h1m3 0h1M8 16.5h1m3 0h1" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</>
),
pin: (
<>
<path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z" />
<circle cx="12" cy="10" r="2.5" />
</>
),
smile: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M8.5 14c.9 1.3 2.1 2 3.5 2s2.6-.7 3.5-2M9 9.8v.4m6-.4v.4" />
</>
),
doc: (
<>
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
<path d="M14 3.5V8h4M9 12h6m-6 3.5h6" />
</>
),
docPlus: (
<>
<path d="M6 3.5h8l4 4V20a.5.5 0 0 1-.5.5h-11A.5.5 0 0 1 6 20V3.5z" />
<path d="M14 3.5V8h4M12 11v6m-3-3h6" />
</>
),
shield: (
<>
<path d="M12 3l7.5 3v5.5c0 4.6-3.2 8.2-7.5 9.5-4.3-1.3-7.5-4.9-7.5-9.5V6L12 3z" />
<path d="M9 12l2 2 4-4" />
</>
),
lock: (
<>
<rect x="5" y="10.5" width="14" height="10" rx="2" />
<path d="M8.5 10.5V7.5a3.5 3.5 0 0 1 7 0v3" />
</>
),
edit: <path d="M5 19l1-4L15.5 5.5a2 2 0 0 1 3 3L9 18l-4 1zM14 7l3 3" />,
trash: <path d="M5 7h14M10 7V4.5h4V7m-7 0l1 12.5h8L17 7M10.5 11v5m3-5v5" />,
logout: <path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14M10 8l-4 4 4 4M6 12h10" />,
send: <path d="M4 12l16-8-6 16-2.5-6.5L4 12z" />,
heart: <path d="M12 19.5s-7.5-4.4-7.5-10a4.2 4.2 0 0 1 7.5-2.6 4.2 4.2 0 0 1 7.5 2.6c0 5.6-7.5 10-7.5 10z" />,
lungs: (
<>
<path d="M12 4v8m0 0c-1 1.2-2.3 2-3.5 2M12 12c1 1.2 2.3 2 3.5 2" />
<path d="M8.5 7.5C6 8 4 11.5 4 16c0 2 1 3.5 3 3.5s3-1.5 3-4V9M15.5 7.5C18 8 20 11.5 20 16c0 2-1 3.5-3 3.5s-3-1.5-3-4V9" />
</>
),
question: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M9.6 9.5a2.4 2.4 0 0 1 4.7.7c0 1.6-2.3 2-2.3 3.6m0 2.7v.3" />
</>
),
message: <path d="M4 5.5h16v11H9.5L5 20v-3.5H4z" />,
plus: <path d="M12 5v14M5 12h14" />,
clipboard: (
<>
<rect x="5" y="5" width="14" height="16" rx="2" />
<path d="M9 3.5h6v3H9zM8.5 11h7M8.5 14.5h7M8.5 18h4" />
</>
),
book: (
<>
<path d="M5 4.5h10.5A2.5 2.5 0 0 1 18 7v13H7.5A2.5 2.5 0 0 1 5 17.5z" />
<path d="M5 17.5A2.5 2.5 0 0 1 7.5 15H18M9 8.5h5" />
</>
),
pause: <path d="M9 6v12M15 6v12" />,
} as const;
export type IconName = keyof typeof paths;
export function Icon({ name, size = 22, label }: { name: IconName; size?: number; label?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
focusable="false"
>
{paths[name]}
</svg>
);
}
export function LogoMark({ size = 32 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect width="32" height="32" rx="9" fill="var(--c-primary)" />
<path d="M16 8v16M8 16h16" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { Icon } from "./Icon";
/*
* Short confirmations («отметил приём», «дневник сохранён») as a toast in the top-right corner instead of a separate
* confirmation page: the person lands on the home screen right away and still sees what happened.
*/
interface Toast {
id: number;
text: string;
}
const Ctx = createContext<(text: string) => void>(() => undefined);
const LIFETIME_MS = 6000;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const next = useRef(0);
const close = useCallback((id: number) => setToasts((all) => all.filter((t) => t.id !== id)), []);
const show = useCallback(
(text: string) => {
const id = ++next.current;
setToasts((all) => [...all.slice(-2), { id, text }]);
window.setTimeout(() => close(id), LIFETIME_MS);
},
[close],
);
const value = useMemo(() => show, [show]);
return (
<Ctx.Provider value={value}>
{children}
<div className="toasts" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className="toast">
<span className="toast__icon" aria-hidden="true">
<Icon name="check" size={18} />
</span>
<p className="toast__text">{t.text}</p>
<button type="button" className="toast__close" onClick={() => close(t.id)} aria-label="Закрыть">
<Icon name="close" size={18} />
</button>
</div>
))}
</div>
</Ctx.Provider>
);
}
export function useToast(): (text: string) => void {
return useContext(Ctx);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Catalog, MedsReference } from "../api/types";
/*
* Static reference data from the server (questionnaire catalog, drug reference): fetched once per app run and
* shared by every screen. A failed fetch is retried on the next mount; screens work without it, only less rich.
*/
function cached<T>(load: () => Promise<T>) {
let value: T | null = null;
let inflight: Promise<T> | null = null;
const get = () => {
inflight ??= load().then(
(v) => (value = v),
(e) => {
inflight = null;
throw e;
},
);
return inflight;
};
return function useCached(): T | null {
const [v, setV] = useState<T | null>(value);
useEffect(() => {
if (value) return;
let alive = true;
get()
.then((x) => alive && setV(x))
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return v;
};
}
export const useCatalog = cached<Catalog>(api.catalog);
export const useMedsReference = cached<MedsReference>(api.medsReference);
+256
View File
@@ -0,0 +1,256 @@
/*
* All interface texts of the patient app (copy from docs/mock). Dialog content — consent, questions, scales,
* reminders — comes from the engine (config/texts/) and is NOT duplicated here.
*/
export const ru = {
product: {
name: "Сопровождение пациента",
tagline: "между визитами к врачу",
workspace: "Личный кабинет",
supportTitle: "Рядом между визитами",
supportText: "Наблюдение за самочувствием и поддержка назначенного лечения.",
},
nav: {
today: "Сегодня",
meds: "Лекарства",
doctor: "Врач",
profile: "Профиль",
messages: "Сообщения",
signIn: "Вход",
help: "Помощь",
},
help: {
block: "Экстренная помощь",
title: "Экстренная помощь",
lead: "Если вам нужна срочная помощь, позвоните сразу.",
call: "Позвонить",
close: "Закрыть",
alwaysNote: "Вы не одни. Помощь доступна круглосуточно.",
fallbackName: "Единый номер экстренных служб",
fallbackNote: "",
},
common: {
back: "Назад",
next: "Далее",
skip: "Пропустить",
send: "Отправить",
retry: "Повторить",
toToday: "Вернуться на сегодня",
recommended: "Рекомендуем",
networkError: "Не удалось подключиться к сервису. Проверьте интернет и повторите.",
},
welcome: {
title: "Вход",
emias: {
title: "Войти через ЕМИАС",
perks: [
"Назначения врача подгружаются автоматически",
"Врач видит ответы и получает сигнал при ухудшении",
"История доступна с любого устройства",
],
},
or: "или",
anonymous: {
title: "Войти анонимно",
text: "Без имени и полиса. Врач не видит ответы, история хранится только на этом устройстве.",
},
disclaimer: "Сервис не ставит диагнозы и не назначает лечение — решения принимает врач.",
},
emias: {
title: "Введите код от врача",
lead: "Это одноразовый код. Он нужен, чтобы безопасно подключить ваши назначения и план наблюдения.",
demoNotice: "Демонстрационный режим: авторизация в ЕМИАС не выполняется, вход подтверждается кодом от врача.",
codeLabel: "Код подключения",
codePlaceholder: "Код из памятки",
submit: "Продолжить",
submitting: "Проверяем код…",
codeError: "Код не подошёл — проверьте написание или попросите врача выдать новый.",
or: "или",
demoTitle: "Демо-пациенты",
demoLead: "Нет кода? Войдите под синтетической учётной записью.",
demoDay: (day: number) => `${day}-й день терапии`,
},
anonymous: {
title: "Что вы хотите проверить?",
note: "Анонимно: врач не видит ответы, история хранится только на этом устройстве.",
focus: {
depression: { title: "Депрессия", text: "Подавленность, потеря интереса, усталость" },
ptsd: { title: "ПТСР", text: "Последствия травмы: флешбэки, кошмары, тревога" },
},
submitting: "Создаём профиль…",
},
today: {
title: "Сегодня",
takeMed: "Принять лекарство",
now: "Сейчас",
checkin: "Отметка самочувствия",
checkinHint: "меньше минуты",
done: "Уже выполнено",
medTaken: "Лекарство",
medTakenAt: (time: string) => `принято, ${time}`,
checkinDone: "Самочувствие отмечено",
allDoneTitle: "На сегодня всё сделано",
allDoneNext: (time: string) => `Следующее напоминание о лекарстве — ${time}`,
allDoneNoMeds: "Следующая отметка самочувствия — завтра",
viewMeds: "Посмотреть лекарства",
visit: "Следующий визит к врачу",
medQuestion: "Вопрос о лекарстве",
medQuestionHint: "Ответ по официальной инструкции",
messageDoctor: "Сообщение врачу",
messageDoctorHint: "Врач ответит в рабочее время",
week: "Приём на этой неделе",
weekTaken: (taken: number, total: number) => (total ? `Принято ${taken} из ${total}` : "Отметок пока нет"),
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы назначения и визиты появились здесь.",
newMessage: "Новое сообщение",
open: "Открыть",
continue: "Продолжить",
intakeStart: "Первичный опрос",
intakeStartHint: "15–20 минут, можно прерваться и продолжить позже",
intakeResume: "Продолжить первичный опрос",
intakeResumeHint: "Ответы сохранены, можно продолжить",
intakeReview:
"Опрос приостановлен: продолжить можно после оценки специалиста, врач уже получил уведомление. Если нужна помощь сейчас — 112.",
intakeDone: "Первичный опрос пройден",
diary: "Дневник самочувствия",
diaryHint: "2–3 минуты: настроение, сон, лекарства",
diaryPart: { morning: "Дневник — утро", evening: "Дневник — вечер" },
diaryPartHint: { morning: "3 вопроса: сон и алкоголь", evening: "Настроение, лекарства и модули по результату опроса" },
results: "Мой результат",
diaryDone: "Дневник заполнен",
therapyDay: (day: number) => `День ${day} лечения`,
effectHint: "Когда ждать эффекта и что важно знать",
},
meds: {
title: "Моя схема приёма",
lead: "По назначению врача. Отмечайте приём препаратов, чтобы не пропускать.",
byDoctor: "По назначению врача",
tablet: "1 приём",
week: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"],
onlyDoctor: "Схему меняет только врач. Не изменяйте дозировку самостоятельно.",
empty: "Назначения появятся здесь после входа через ЕМИАС.",
question: "Вопрос о лекарстве",
reminderTitle: "Время принять препарат",
reminderLead: "По назначению врача",
taken: "Принял(а)",
skip: "Пропущу",
sideEffects: "Есть побочные эффекты",
about: "О препарате",
reportSideEffect: "Сообщить о побочном эффекте",
therapyDay: (day: number) => `День лечения: ${day}`,
card: {
back: "К схеме приёма",
prescribed: "Назначено",
effect: "Когда ждать эффекта",
effectAt: (when: string) => `Эффект оценивают через ${when}`,
progress: (day: number) => `Вы принимаете препарат ${day} ${day % 10 === 1 && day % 100 !== 11 ? "день" : [2, 3, 4].includes(day % 10) && ![12, 13, 14].includes(day % 100) ? "дня" : "дней"}`,
course: "Сколько принимать",
courseShort: (days: number) => `Обычно не дольше ${Math.round(days / 7)} недель — срок определяет врач`,
sideEffects: "Что может беспокоить",
sideEffectsHint:
"Побочные эффекты часто ослабевают в первые недели. Если они мешают жить, сообщите врачу — он может изменить дозу или подобрать другой препарат. Не меняйте схему сами.",
sideEffectsNone: "Частые побочные эффекты в клинических рекомендациях не описаны — спросите врача.",
redFlags: "Когда срочно к врачу",
important: "Важно",
withdrawal: "Резкая отмена этого препарата может вызвать неприятные ощущения: головокружение, раздражительность, нарушения сна. Отменяют его постепенно и только с врачом.",
overdose: "Храните препарат в недоступном для других месте и принимайте строго в назначенной дозе.",
monitoring: "Врач может назначить обследования",
instruction: "Как принимать, что делать при пропуске, алкоголь и вождение",
ask: "Задать вопрос о лекарстве",
source: (sources: string) => `Источник: ${sources}. Черновик справочника, проверяется врачом-психиатром.`,
offLabel: "Назначение вне зарегистрированных показаний — так бывает, решение принимает врач.",
unknown: "Справки по этому препарату пока нет. Уточните у врача или в инструкции к препарату.",
},
},
doctor: {
title: "Врач",
visit: "Следующий визит",
visitNone: "Дата следующего визита пока не назначена",
attending: "Ваш лечащий врач",
write: "Написать врачу",
writeNote: "Обычно врач отвечает в рабочее время. Если срочно — 112.",
earlier: "Записаться к врачу раньше",
plan: "Мой план безопасности",
noDoctor: "Врач не подключён. Подключите ЕМИАС в профиле, чтобы связаться с лечащим врачом.",
},
profile: {
title: "Профиль",
signInMode: "Способ входа",
emias: "ЕМИАС",
anonymous: "Анонимно",
connectEmias: "Подключить ЕМИАС",
data: "Мои данные",
revoke: "Отозвать согласие",
revokeTitle: "Отозвать согласие?",
revokeText: "Ваши ответы больше не будут обрабатываться в приложении.",
revokeConfirm: "Да, отозвать",
cancel: "Отмена",
wipe: "Удалить данные с устройства",
wipeNote: "Для анонимного режима: история на этом устройстве будет удалена.",
logout: "Выйти",
messages: "Сообщения",
result: "Предварительный результат",
resultNone: "Появится после первичного опроса.",
history: "История опросов",
historySub: "Все опросы и дневники с датами и результатами",
},
results: {
title: "Мои результаты",
conclusion: "Предварительный результат",
conclusionDay: (d: string) => `Первичный опрос от ${d}`,
modules: { depression: "Модуль «Депрессия»", veteran: "Модуль «ПТСР»" },
history: "История опросов",
empty: "Здесь появятся пройденные опросы.",
noResult: "Пройдите первичный опрос — после него здесь будет результат.",
toIntake: "Пройти первичный опрос",
},
flow: {
home: "На главный экран",
question: (n: number, total: number) => `Вопрос ${n} из ${total}`,
period: "За последний месяц",
textPlaceholder: "Напишите несколько слов",
sending: "Отправляем…",
doneTitle: "Готово",
resultTitle: "Результат",
sendEmpty: "Отправить без комментария",
doneLead: "Врач увидит отметку.",
breathe: "Вдох на 4 — выдох на 6",
breatheIn: "Вдох",
breatheOut: "Выдох",
openPlan: "Открыть мой план безопасности",
supportTitle: "Вы не одни",
exit: "К главной",
planTitle: "Мой план безопасности",
consentTitle: "Согласие на обработку данных о здоровье",
sections: {
safety: "Безопасность",
mood: "Настроение",
trauma: "Пережитый опыт",
alcohol: "Алкоголь",
about: "О вас",
diary: "Дневник",
scale: "Опросник",
},
questionN: (n: number) => `Вопрос ${n}`,
recall: (period: string) => `Период: ${period}`,
hoursLabel: "Сколько часов",
hoursUnit: "ч",
hoursHint: "От 0 до 24, можно с половиной: 6,5",
portionsLabel: "Сколько порций",
portionsUnit: "порц.",
portionsHint: "Если не пили — 0. Можно дробное: 1,5",
drinks: "Таблица стандартных порций",
drinksLead: "Посчитайте, сколько порций выходит в обычный день, когда вы выпиваете.",
noticeTitle: "Опрос приостановлен",
},
messages: {
title: "Сообщения",
inputLabel: "Сообщение",
inputPlaceholder: "Напишите сообщение",
you: "Вы",
empty: "Здесь будут сообщения помощника.",
},
footer: {
law: "Обработка данных о здоровье — только с вашего согласия (152‑ФЗ).",
prototype: "Прототип для хакатона MedITron. Используются только синтетические данные.",
},
} as const;
+115
View File
@@ -0,0 +1,115 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api } from "../api/client";
import type { DialogEvent, Reply } from "../api/types";
import { useSession } from "../session/SessionContext";
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
/*
* One conversation with the dialog engine per signed-in patient, shared by every screen:
* focus screens render `pending` (the latest unanswered batch), «Сообщения» renders `entries`.
* The engine owns the logic (consent, onboarding, scales, check-ins, meds, crisis) — exactly as in Telegram.
*/
const OUTBOX_POLL_MS = 6000;
let seq = 0;
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
interface DialogApi extends DialogSnapshot {
busy: boolean;
error: boolean;
started: boolean;
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
send: (event: DialogEvent, echo?: string) => Promise<boolean>;
start: () => Promise<boolean>;
markRead: () => void;
}
const Ctx = createContext<DialogApi | null>(null);
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
export function DialogProvider({ children }: { children: ReactNode }) {
const { session } = useSession();
const token = session?.token ?? null;
const [owner, setOwner] = useState(token);
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const tokenRef = useRef(token);
tokenRef.current = token;
// Switching accounts: reset to that token's snapshot during render, so it is never saved under another token.
if (owner !== token) {
setOwner(token);
setSnap(token ? sessionStore.loadDialog(token) : EMPTY);
setError(false);
}
useEffect(() => {
if (token && owner === token) sessionStore.saveDialog(token, snap);
}, [token, owner, snap]);
const send = useCallback(
async (event: DialogEvent, echo?: string) => {
const t = tokenRef.current;
if (!t) return false;
setBusy(true);
setError(false);
try {
const replies = await api.sendEvent(t, event);
setSnap((prev) => ({
entries: [
...prev.entries,
...(echo ? [{ id: newId(), from: "patient" as const, text: echo }] : []),
...asEntries(replies),
],
pending: replies,
unread: 0,
}));
return true;
} catch {
setError(true);
return false;
} finally {
setBusy(false);
}
},
[],
);
const start = useCallback(() => send({ type: "start" }), [send]);
const markRead = useCallback(() => setSnap((prev) => (prev.unread ? { ...prev, unread: 0 } : prev)), []);
// System-initiated messages (reminders, check-ins, scales) arrive through the outbox.
useEffect(() => {
if (!token) return;
const timer = window.setInterval(() => {
api
.outbox(token)
.then((replies) => {
if (!replies.length || tokenRef.current !== token) return;
setSnap((prev) => ({
entries: [...prev.entries, ...asEntries(replies)],
pending: replies,
unread: prev.unread + replies.length,
}));
})
.catch(() => undefined);
}, OUTBOX_POLL_MS);
return () => window.clearInterval(timer);
}, [token]);
const value = useMemo<DialogApi>(
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
[snap, busy, error, send, start, markRead],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useDialog(): DialogApi {
const value = useContext(Ctx);
if (!value) throw new Error("useDialog outside DialogProvider");
return value;
}
+189
View File
@@ -0,0 +1,189 @@
import type { DialogButton, Reply } from "../api/types";
/*
* Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is
* asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen.
*/
export type Step =
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
| { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
| { kind: "crisis"; text: string; buttons: DialogButton[] }
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
| { kind: "number"; lead: string[]; title: string; options: DialogButton[] }
| { kind: "actions"; lead: string[]; title: string; options: DialogButton[] }
| { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress; formId?: string }
| { kind: "text"; lead: string[]; title: string; skip?: DialogButton }
| Survey
// A questionnaire stopped: paused by the patient (resume button) or held for a specialist (help button only).
| { kind: "notice"; text: string[]; primary?: DialogButton; help?: DialogButton };
/** One item of a versioned questionnaire (app/dialog/flows/intake.py → question_reply). */
export interface Survey {
kind: "survey";
itemId: string;
instruction: string[]; // form instruction / recall period above the question
title: string; // the item text, verbatim
footnotes: string[]; // «*…» explanations printed under the item on the paper form
options: DialogButton[];
input: "choice" | "number" | "hours" | "portions" | "text";
anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item
asOf?: string; // ISO date the recall period counts from
skip?: DialogButton;
help?: DialogButton;
pause?: DialogButton;
}
export interface Progress {
n: number;
total: number;
}
// config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own.
const MENU_PROMPT = "Чем могу помочь?";
// config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}"
const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/;
// config/texts/intake.yaml → as_of: "Период отсчитывается от даты начала этого опроса: {day}."
const AS_OF = /^Период отсчитывается[^:]*:\s*(\d{4}-\d{2}-\d{2})\.?$/;
// config/texts/intake.yaml → standard_drinks: printed inside the RUS-AUDIT item 2 instruction; shown as a table.
const DRINKS_START = "Стандартная порция";
const ANCHORS = /^\d+\s*—/;
const IQ = /^iq:[^:]+:([^:]+):(\d+|skip)$/;
const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:"));
const flat = (r: Reply) => r.buttons.flat();
const paragraphs = (text: string) =>
text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
/** Splits a reply into a title (the actual question) and lead paragraphs above it. */
function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } {
const parts = texts.flatMap(paragraphs);
const last = parts.pop() ?? "";
const m = SCALE_ITEM.exec(last);
if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } };
// A long last paragraph is body text, not a heading.
if (last.length > 220) return { lead: [...parts, last], title: "" };
return { lead: parts, title: last };
}
function survey(batch: Reply[], qIndex: number): Survey | null {
const buttons = flat(batch[qIndex]);
const iq = buttons.map((b) => IQ.exec(b.data)).find(Boolean);
const help = buttons.find((b) => b.data === "intake:help");
const pause = buttons.find((b) => b.data === "intake:pause");
if (!pause || !help) return null;
// A free-text / numeric item has no answer buttons: the item id is then unknown, the text is still verbatim.
const itemId = iq?.[1] ?? "";
const options = buttons.filter((b) => IQ.test(b.data) && !b.data.endsWith(":skip"));
const skip = buttons.find((b) => b.data.startsWith("iq:") && b.data.endsWith(":skip"));
let asOf: string | undefined;
const parts = paragraphs(batch[qIndex].text).filter((p) => {
const m = AS_OF.exec(p);
if (m) asOf = m[1];
return !m;
});
const footnotes = parts.filter((p) => p.startsWith("*"));
const body = parts.filter((p) => !p.startsWith("*"));
let title = body.pop() ?? "";
let anchors: string[] | undefined;
if (ANCHORS.test(title) && body.length) {
anchors = title.replace(/\.$/, "").split(/,\s*(?=\d+\s*—)/);
title = body.pop() ?? "";
}
const instruction = body
.map((p) => (p.includes(DRINKS_START) ? p.slice(0, p.indexOf(DRINKS_START)).trim() : p))
.filter(Boolean);
// Replies before the question in the same batch (e.g. «invalid answer» note) stay visible above it.
const before = batch.slice(0, qIndex).map((r) => r.text.trim()).filter(Boolean);
const input =
options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim()))
? "number"
: options.length
? "choice"
: skip
? "text"
: // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours.
/порци/i.test(title)
? "portions"
: "hours";
return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause };
}
export function classify(batch: Reply[]): Step | null {
if (batch.length === 0) return null;
const crisis = batch.find((r) => r.kind === "crisis");
if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) };
if (batch.some(isMenu)) {
const notes = batch
.filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT)
.map((r) => r.text.trim())
.filter((t) => t && t !== MENU_PROMPT);
return { kind: "idle", notes };
}
const plan = batch.find((r) => r.kind === "safety_plan");
if (plan) {
const [title, ...rest] = plan.text.split("\n").map((l) => l.trim());
const lead = batch.filter((r) => r !== plan).map((r) => r.text);
return {
kind: "plan",
lead,
title,
lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")),
buttons: batch.flatMap(flat),
};
}
// The question is the last reply with buttons; everything before it is context.
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
const texts = batch.map((r) => r.text);
if (qIndex === -1) {
// Without buttons the engine waits for free text only when it marks the reply as a question; anything else
// («отметил приём») is a confirmation.
if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) };
const { lead, title } = titled(texts);
return { kind: "text", lead, title };
}
const options = flat(batch[qIndex]);
const s = survey(batch, qIndex);
if (s) return s;
const resume = options.find((b) => b.data === "intake:resume" || b.data === "intake:start");
const helpOnly = options.length === 1 && options[0].data === "intake:help";
if ((resume && options.length === 1) || helpOnly)
return { kind: "notice", text: texts.filter(Boolean), primary: resume, help: helpOnly ? options[0] : undefined };
const head = titled(texts.slice(0, qIndex + 1));
const tail = texts.slice(qIndex + 1); // rare: info after the question
const lead = [...head.lead, ...tail];
if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options };
if (options.every((b) => b.data.endsWith(":skip")))
return { kind: "text", lead, title: head.title, skip: options[0] };
if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim())))
return { kind: "number", lead, title: head.title, options };
if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data)))
return { kind: "actions", lead, title: head.title, options };
const formId = /^scale:([^:]+):/.exec(options[0]?.data ?? "")?.[1];
return { kind: "choice", lead, title: head.title, options, progress: head.progress, formId };
}
/** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */
export function lines(text: string): { list: boolean; text: string }[] {
const out: { list: boolean; text: string }[] = [];
for (const raw of text.split("\n")) {
const l = raw.trim();
if (!l) continue;
if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") });
else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text))
out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` };
else out.push({ list: false, text: l });
}
return out;
}
+65
View File
@@ -0,0 +1,65 @@
import "./theme/tokens.css";
import "./theme/global.css";
import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { HelpProvider } from "./components/HelpSheet";
import { ToastProvider } from "./components/Toast";
import { DialogProvider } from "./dialog/DialogContext";
import { AnonymousSignIn } from "./screens/AnonymousSignIn";
import { Doctor } from "./screens/Doctor";
import { DrugInfo } from "./screens/DrugInfo";
import { EmiasSignIn } from "./screens/EmiasSignIn";
import { Flow } from "./screens/Flow";
import { DoseReminder, Meds } from "./screens/Meds";
import { Messages } from "./screens/Messages";
import { Profile } from "./screens/Profile";
import { Results } from "./screens/Results";
import { Today } from "./screens/Today";
import { Welcome } from "./screens/Welcome";
import { SessionProvider, useSession } from "./session/SessionContext";
function Private({ children }: { children: ReactNode }) {
const { session } = useSession();
return session ? <>{children}</> : <Navigate to="/" replace />;
}
// Hash routing works the same on a static host, file:// and inside the Capacitor WebView.
function AppRoutes() {
const { session } = useSession();
return (
<Routes>
<Route path="/" element={session ? <Navigate to="/today" replace /> : <Welcome />} />
<Route path="/emias" element={<EmiasSignIn />} />
<Route path="/anonymous" element={<AnonymousSignIn />} />
<Route path="/flow" element={<Private><Flow /></Private>} />
<Route path="/today" element={<Private><Today /></Private>} />
<Route path="/dose/:id" element={<Private><DoseReminder /></Private>} />
<Route path="/meds" element={<Private><Meds /></Private>} />
<Route path="/meds/:ref" element={<Private><DrugInfo /></Private>} />
<Route path="/doctor" element={<Private><Doctor /></Private>} />
<Route path="/profile" element={<Private><Profile /></Private>} />
<Route path="/results" element={<Private><Results /></Private>} />
<Route path="/messages" element={<Private><Messages /></Private>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<SessionProvider>
<DialogProvider>
<HelpProvider>
<ToastProvider>
<HashRouter>
<AppRoutes />
</HashRouter>
</ToastProvider>
</HelpProvider>
</DialogProvider>
</SessionProvider>
</StrictMode>,
);
+66
View File
@@ -0,0 +1,66 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Focus } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
const FOCUS: { id: Focus; icon: IconName }[] = [
{ id: "depression", icon: "smile" },
{ id: "ptsd", icon: "heart" },
];
// A3 · Анонимный вход: человек выбирает, что хочет проверить — от этого зависит стартовый опрос
export function AnonymousSignIn() {
const t = ru.anonymous;
const navigate = useNavigate();
const { signIn } = useSession();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const start = async (focus: Focus) => {
setBusy(true);
setError(false);
try {
signIn(await api.signInAnonymous(focus));
navigate("/flow", { replace: true });
} catch {
setError(true);
setBusy(false);
}
};
return (
<AppShell signedIn={false} focus>
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="stack signin">
<h1 className="h1">{t.title}</h1>
<div className="entry-grid">
{FOCUS.map(({ id, icon }) => (
<button key={id} type="button" className="entry" onClick={() => start(id)} disabled={busy}>
<span className="entry__top">
<span className="tile-icon">
<Icon name={icon} size={26} />
</span>
</span>
<span className="entry__title">
{t.focus[id].title}
<Icon name="chevronRight" />
</span>
<span className="entry__text">{t.focus[id].text}</span>
</button>
))}
</div>
{error && <Notice tone="danger">{ru.common.networkError}</Notice>}
<p className="muted small">{busy ? t.submitting : t.note}</p>
</div>
</div>
</AppShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useNavigate } from "react-router-dom";
import { AppShell, Notice } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { humanDate, useToday } from "./useToday";
// F1 / F2 · Врач: следующий визит и связь с врачом (действия выполняет движок диалога)
export function Doctor() {
const t = ru.doctor;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
const actions: { button: string; label: string; icon: IconName }[] = [
{ button: "menu:contact_doctor", label: t.write, icon: "message" },
{ button: "menu:visit", label: t.earlier, icon: "calendar" },
{ button: "safety_plan", label: t.plan, icon: "shield" },
];
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
</header>
{plan && !plan.has_doctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<div className="cols">
<section className="card-list" aria-label={t.visit}>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span>
<span className="row-card__sub">{t.visit}</span>
<span className="row-card__title">{plan?.next_visit ? humanDate(plan.next_visit) : t.visitNone}</span>
</span>
</div>
<div className="card-list__row">
<span className="tile-icon tile-icon--blue">
<Icon name="doctor" />
</span>
<span className="row-card__title">{t.attending}</span>
</div>
</section>
<div className="stack stack--sm">
{actions.map((a) => (
<button key={a.button} type="button" className="row-card" onClick={() => go(a.button, a.label)} disabled={dialog.busy}>
<span className="tile-icon">
<Icon name={a.icon} />
</span>
<span className="row-card__text">
<span className="row-card__title">{a.label}</span>
</span>
<Icon name="chevronRight" />
</button>
))}
</div>
<Notice>{t.writeNote}</Notice>
</div>
)}
</div>
</AppShell>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useNavigate, useParams } from "react-router-dom";
import type { DrugRef, Indication, MedsReference } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { useMedsReference } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { sendSideEffect } from "./Meds";
import { useToday } from "./useToday";
/*
* E6 · О препарате (spec «Модуль Лекарства»: карточка препарата). Patient information from the drug reference
* built on the Minzdrav guidelines: when to expect the effect, how long, what may bother, when to see a doctor
* urgently. No doses or ranges are shown — the prescription comes from the doctor only.
*/
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** «3–4 недели» → the upper bound in days, for the progress bar (null when the text has no weeks). */
function weeksToDays(text: string | null | undefined): number | null {
const m = /(\d+)(?:\s*[–-]\s*(\d+))?\s*недел/.exec(text ?? "");
return m ? Number(m[2] ?? m[1]) * 7 : null;
}
function Section({ icon, tone, title, children }: { icon: IconName; tone?: string; title: string; children: React.ReactNode }) {
return (
<section className="drug__sec">
<h2 className="drug__h">
<span className={`badge-icon ${tone ? `badge-icon--${tone}` : ""}`}>
<Icon name={icon} size={18} />
</span>
{title}
</h2>
{children}
</section>
);
}
function EffectTimeline({ day, horizon, when }: { day: number; horizon: number; when: string }) {
const pct = Math.min(100, (day / horizon) * 100);
return (
<div className="timeline" aria-label={ru.meds.card.progress(day)}>
<div className="timeline__bar">
<span className="timeline__fill" style={{ width: `${pct}%` }} />
<span className="timeline__mark" style={{ left: `${pct}%` }} />
</div>
<div className="timeline__legend">
<span>{ru.meds.card.progress(day)}</span>
<span>{ru.meds.card.effectAt(when)}</span>
</div>
</div>
);
}
function Card({
drug,
reference: ref,
indication,
day,
prescribed,
}: {
drug: DrugRef;
reference: MedsReference;
indication: Indication | null;
day: number | null;
prescribed?: string;
}) {
const c = ru.meds.card;
const ind = indication ? drug.indications[indication] : Object.values(drug.indications)[0];
const expect = indication === "ptsd" ? ref.texts.expect_effect_ptsd : ref.texts.expect_effect_depression;
const horizon = weeksToDays(ind?.expected_response);
const sources = ind?.source ?? "";
return (
<div className="drug">
<header className="drug__head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={32} />
</span>
<div>
<h1 className="h1">{drug.name_ru}</h1>
<p className="muted">
{drug.group}
{drug.group !== drug.class && `, ${drug.class}`}
</p>
{prescribed && (
<p className="drug__rx">
<Icon name="doctor" size={18} />
{c.prescribed}: {prescribed}
</p>
)}
</div>
</header>
<div className="drug__grid">
<div className="drug__col">
{ind?.expected_response && (
<Section icon="clock" tone="blue" title={c.effect}>
{day !== null && horizon && <EffectTimeline day={day} horizon={horizon} when={ind.expected_response} />}
<p>{expect}</p>
</Section>
)}
{(ind?.course || drug.max_course_days) && (
<Section icon="calendar" tone="blue" title={c.course}>
{ind?.course && <p>{ind.course}.</p>}
{drug.max_course_days && <p>{c.courseShort(drug.max_course_days)}.</p>}
{indication === "depression" && <p className="muted">{ref.texts.why_continue_depression}</p>}
</Section>
)}
<Section icon="smile" tone="teal" title={c.sideEffects}>
{drug.diary_items.length ? (
<ul className="bullets">
{drug.diary_items.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
) : (
<p>{c.sideEffectsNone}</p>
)}
<p className="muted">{c.sideEffectsHint}</p>
</Section>
</div>
<div className="drug__col">
{drug.red_flags.length > 0 && (
<section className="drug__sec drug__sec--red">
<h2 className="drug__h">
<span className="badge-icon badge-icon--red">
<Icon name="alert" size={18} />
</span>
{c.redFlags}
</h2>
<ul className="bullets">
{drug.red_flags.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
<p>{ref.texts.red_flag}</p>
</section>
)}
<Section icon="shield" tone="blue" title={c.important}>
<p>{ref.texts.dont_stop_alone}</p>
{drug.withdrawal_risk === "high" && <p>{c.withdrawal}</p>}
{drug.overdose_danger && <p>{c.overdose}</p>}
{ind?.off_label && <p className="muted">{c.offLabel}</p>}
{drug.monitoring.length > 0 && (
<>
<p className="drug__sub">{c.monitoring}:</p>
<ul className="bullets">
{drug.monitoring.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</>
)}
</Section>
<Section icon="doc" tone="blue" title={c.instruction}>
<p>{ref.texts.instruction_fallback}</p>
</Section>
</div>
</div>
<p className="muted small">{c.source(sources)}</p>
</div>
);
}
export function DrugInfo() {
const c = ru.meds.card;
const { ref: refId } = useParams();
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const ref = useMedsReference();
const drug = ref?.drugs.find((d) => d.id === refId);
const rx = plan?.prescriptions.find((p) => p.ref_id === refId);
const ask = () =>
void dialog.send({ type: "button", button: "menu:med_question" }, c.ask).then((ok) => ok && navigate("/flow"));
return (
<AppShell>
<div className="stack">
<TopBar onBack={() => navigate("/meds")} />
{!ref ? (
<div className="loader" aria-label="Загрузка" />
) : !drug ? (
<Notice>{c.unknown}</Notice>
) : (
<Card
drug={drug}
reference={ref}
indication={rx?.indication ?? null}
day={rx?.therapy_day ?? null}
prescribed={rx ? `${cap(rx.drug)}, ${rx.dose} · ${rx.times.join(", ")}` : undefined}
/>
)}
{rx && (
<div className="btn-row btn-row--stack-phone">
<button
type="button"
className="btn btn--outline-red"
disabled={dialog.busy}
onClick={() => sendSideEffect(dialog, navigate, plan, rx.drug, ru.meds.reportSideEffect)}
>
<Icon name="alert" />
{ru.meds.reportSideEffect}
</button>
{plan?.has_doctor && (
<button type="button" className="btn btn--outline" disabled={dialog.busy} onClick={ask}>
<Icon name="question" />
{c.ask}
</button>
)}
</div>
)}
</div>
</AppShell>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError, api } from "../api/client";
import type { AuthResult, DemoPersona } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
// A2 · Вход через ЕМИАС (демо: одноразовый код от врача или синтетический пациент)
export function EmiasSignIn() {
const t = ru.emias;
const navigate = useNavigate();
const { signIn } = useSession();
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [personas, setPersonas] = useState<DemoPersona[] | null>(null);
useEffect(() => {
api
.demoPersonas()
.then(setPersonas)
.catch(() => setPersonas([]));
}, []);
const finish = (auth: AuthResult) => {
signIn(auth);
navigate("/flow", { replace: true });
};
const fail = (e: unknown) => {
setError(e instanceof ApiError && e.status === 401 ? t.codeError : ru.common.networkError);
setBusy(false);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!code.trim() || busy) return;
setBusy(true);
setError(null);
try {
finish(await api.signInEmias(code.trim()));
} catch (err) {
fail(err);
}
};
const demo = async (persona: DemoPersona) => {
setBusy(true);
setError(null);
try {
finish(await api.signInEmiasDemo(persona.token));
} catch (err) {
fail(err);
}
};
return (
<AppShell signedIn={false} focus>
<div className="focus">
<TopBar onBack={() => navigate("/")} />
<div className="split">
<div className="split__lead">
<header className="head">
<span className="tile-icon tile-icon--teal">
<Icon name="docPlus" size={26} />
</span>
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
<Notice>{t.demoNotice}</Notice>
</div>
<div className="split__act">
<form className="stack stack--sm" onSubmit={submit} noValidate>
<label htmlFor="code" className="visually-hidden">
{t.codeLabel}
</label>
<input
id="code"
className={error ? "code-input is-error" : "code-input"}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t.codePlaceholder}
autoComplete="one-time-code"
autoCapitalize="characters"
spellCheck={false}
maxLength={32}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "code-error" : undefined}
/>
{error && (
<div id="code-error" role="alert">
<Notice tone="danger">{error}</Notice>
</div>
)}
<button type="submit" className="btn btn--primary" disabled={busy || !code.trim()}>
{busy ? t.submitting : t.submit}
</button>
</form>
<div className="divider" role="separator">
<span>{t.or}</span>
</div>
<section className="stack stack--sm" aria-labelledby="demo-title">
<h2 id="demo-title" className="h3">
{t.demoTitle}
</h2>
<p className="muted">{t.demoLead}</p>
{personas?.map((p) => (
<button key={p.token} type="button" className="row-card" onClick={() => demo(p)} disabled={busy}>
<span className="avatar">{p.display_name.charAt(0)}</span>
<span className="row-card__text">
<span className="row-card__title">{p.display_name}</span>
<span className="row-card__sub">{t.demoDay(p.therapy_day)}</span>
</span>
<Icon name="chevronRight" />
</button>
))}
</section>
</div>
</div>
</div>
</AppShell>
);
}
+730
View File
@@ -0,0 +1,730 @@
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step, type Survey } from "../dialog/step";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3),
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
*/
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
return (
<>
{text.split(PHONE_RE).map((part, i) =>
i % 2 === 1 ? (
<a key={i} href={telHref(part)} className="tel">
{part}
</a>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
function Body({ texts }: { texts: string[] }) {
if (!texts.length) return null;
return (
<div className="body-text">
{texts.map((t, i) => {
const ls = lines(t);
return (
<div key={i} className="body-text__block">
{ls.map((l, j) =>
l.list ? (
<ul key={j} className="bullets">
<li>
<RichText text={l.text} />
</li>
</ul>
) : (
<p key={j}>
<RichText text={l.text} />
</p>
),
)}
</div>
);
})}
</div>
);
}
function Progress({ n, total, adaptive = false }: { n: number; total: number; adaptive?: boolean }) {
// Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar.
if (adaptive)
return (
<div className="progress">
<span className="progress__label">{ru.flow.questionN(n)}</span>
</div>
);
return (
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
<span className="progress__bar">
<span style={{ width: `${(n / total) * 100}%` }} />
</span>
<span className="progress__label">{ru.flow.question(n, total)}</span>
</div>
);
}
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
return (
<section className="drinks" aria-label={ru.flow.drinks}>
<h2 className="h3">{ru.flow.drinks}</h2>
<p className="muted small">{ru.flow.drinksLead}</p>
<div className="drinks__scroll">
<table>
<thead>
<tr>
<th scope="col">СП</th>
{table.header.map((h) => (
<th key={h} scope="col">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map(([label, cells]) => (
<tr key={label}>
<th scope="row">{label.replace(/\s*СП$/, "")}</th>
{cells.map((c, i) => (
<td key={i}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="muted small">
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
</p>
</section>
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
<header className="head">
{icon && (
<span className="tile-icon tile-icon--blue">
<Icon name={icon} size={26} />
</span>
)}
{title && <h1 className="h1">{title}</h1>}
<Body texts={lead} />
</header>
);
}
function Choice({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="options" role="radiogroup">
{options.map((b) => {
const on = picked?.data === b.data;
return (
<button
key={b.data}
type="button"
role="radio"
aria-checked={on}
className={on ? "option is-on" : "option"}
onClick={() => setPicked(b)}
>
<span className="option__mark">{on && <Icon name="check" size={16} />}</span>
<span className="option__label">{b.label}</span>
</button>
);
})}
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
function NumberScale({
options,
busy,
onSubmit,
anchors,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
anchors?: string[];
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="numscale">
<span className="numscale__value" aria-live="polite">
{picked ? picked.label : "–"}
</span>
<div className="numscale__row" role="radiogroup" style={{ "--n": options.length } as CSSProperties}>
{options.map((b) => (
<button
key={b.data}
type="button"
role="radio"
aria-checked={picked?.data === b.data}
className={picked?.data === b.data ? "num is-on" : "num"}
onClick={() => setPicked(b)}
>
{b.label}
</button>
))}
</div>
{anchors && anchors.length === 2 && (
<div className="numscale__ends">
<span>{anchors[0]}</span>
<span>{anchors[1]}</span>
</div>
)}
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
function FreeText({
skip,
busy,
onSend,
onSkip,
onExit,
}: {
skip?: DialogButton;
busy: boolean;
onSend: (text: string) => void;
onSkip: (b: DialogButton) => void;
onExit: () => void;
}) {
const [text, setText] = useState("");
return (
<>
<div className="textarea">
<label htmlFor="free" className="visually-hidden">
{ru.messages.inputLabel}
</label>
<textarea
id="free"
rows={5}
maxLength={500}
value={text}
placeholder={ru.flow.textPlaceholder}
onChange={(e) => setText(e.target.value)}
/>
<span className="textarea__count">{text.length}/500</span>
</div>
<div className="btn-row">
{skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
{skip.label}
</button>
) : (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={onExit}>
{ru.flow.exit}
</button>
)}
<button
type="button"
className="btn btn--primary"
disabled={busy}
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
>
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
</button>
</div>
</>
);
}
const NUMERIC = {
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
};
function HoursInput({
busy,
onSend,
kind = "hours",
}: {
busy: boolean;
onSend: (text: string) => void;
kind?: keyof typeof NUMERIC;
}) {
const f = NUMERIC[kind];
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
return (
<form
className="hours"
onSubmit={(e) => {
e.preventDefault();
if (ok) onSend(v.trim());
}}
>
<label htmlFor="hours" className="hours__label">
{f.label}
</label>
<div className="hours__field">
<input
id="hours"
inputMode="decimal"
autoComplete="off"
value={v}
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{f.unit}</span>
</div>
<p id="hours-hint" className="muted small">
{f.hint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
</button>
</form>
);
}
// No form name or section above the question: the person should not see what a questionnaire screens for while
// answering it. Names and results appear only after it is finished (Results screen).
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
return (
<>
<header className="head survey">
{step.instruction.length > 0 && (
<div className="survey__instr">
<Body texts={step.instruction} />
</div>
)}
<h1 className="h1 survey__q">{step.title}</h1>
{step.footnotes.map((f) => (
<p key={f} className="muted small">
{f}
</p>
))}
</header>
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
</>
);
}
function SurveyAct({
step,
stepKey,
busy,
press,
say,
onPause,
onHome,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
onPause: (b: DialogButton) => void;
onHome: () => void;
}) {
let answer: ReactNode;
switch (step.input) {
case "number":
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
break;
case "hours":
case "portions":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
break;
case "text":
answer = (
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={onHome} />
);
break;
default:
answer = (
<>
<Choice key={stepKey} options={step.options} busy={busy} onSubmit={press} />
{step.skip && (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => press(step.skip!)}>
{step.skip.label}
</button>
)}
</>
);
}
return (
<>
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
)}
{step.help && (
<button type="button" className="linkbtn linkbtn--danger" disabled={busy} onClick={() => press(step.help!)}>
<Icon name="alert" size={18} />
{step.help.label}
</button>
)}
</div>
</>
);
}
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
const style = (b: DialogButton) =>
b.data.startsWith("med:taken")
? { cls: "act act--teal", icon: "check" as IconName }
: b.data.startsWith("med:side_effects")
? { cls: "act act--red", icon: "alert" as IconName }
: { cls: "act", icon: "clock" as IconName };
return (
<div className="acts">
{options.map((b) => {
const s = style(b);
return (
<button key={b.data} type="button" className={s.cls} disabled={busy} onClick={() => onPress(b)}>
<span className="act__icon">
<Icon name={s.icon} size={20} />
</span>
{b.label}
</button>
);
})}
</div>
);
}
function Breathing() {
return (
<section className="breath" aria-label={ru.flow.breathe}>
<Icon name="lungs" size={28} />
<div className="breath__text">
<p className="breath__title">{ru.flow.breathe}</p>
<div className="breath__viz">
<span>{ru.flow.breatheIn}</span>
<span className="breath__ring" aria-hidden="true" />
<span>{ru.flow.breatheOut}</span>
</div>
</div>
</section>
);
}
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
// explains it. Results stay in «Мои результаты» with the date.
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
const [head = ru.flow.doneTitle, ...rest] = notes
.join("\n\n")
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{head}</h1>
{rest.length > 0 && (
<div className="panel">
<Body texts={rest} />
</div>
)}
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
<button type="button" className="btn btn--soft" onClick={onResults}>
{ru.today.results}
</button>
</div>
);
}
const SHORT_PARAGRAPHS = 3;
export function Flow() {
const navigate = useNavigate();
const toast = useToast();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
const home = () => navigate("/today");
// First visit after sign-in: the engine opens the dialog (consent → age → onboarding), as /start in Telegram.
useEffect(() => {
if (!dialog.started && !dialog.busy) void dialog.start();
}, []);
useEffect(() => {
dialog.markRead();
}, [dialog.pending]);
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
// result (the conclusion of the first questionnaire) keeps its own screen.
const toasted = useRef("");
useEffect(() => {
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
const texts = step.kind === "ack" ? step.text : step.notes;
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
if (paragraphs.length && toasted.current !== stepKey) {
toasted.current = stepKey;
toast(paragraphs.join(" "));
}
navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) =>
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
if (!step) {
content = dialog.error ? (
<div className="stack narrow">
<Notice tone="danger">{ru.common.networkError}</Notice>
<button type="button" className="btn btn--primary" onClick={() => void dialog.start()}>
{ru.common.retry}
</button>
</div>
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "ack") {
content = <div className="loader" aria-label="Загрузка" />;
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
<span className="tile-icon tile-icon--blue tile-icon--xl center-self" aria-hidden="true">
<Icon name={step.help ? "shield" : "pause"} size={32} />
</span>
<h1 className="h1 center">{ru.flow.noticeTitle}</h1>
<div className="panel">
<Body texts={step.text} />
</div>
{step.primary && (
<button type="button" className="btn btn--primary" disabled={dialog.busy} onClick={() => press(step.primary!)}>
{step.primary.label}
</button>
)}
{step.help && (
<button type="button" className="btn btn--danger" disabled={dialog.busy} onClick={() => press(step.help!)}>
{step.help.label}
</button>
)}
<button type="button" className="btn btn--soft" onClick={home}>
{ru.common.toToday}
</button>
</div>
);
} else {
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
const progress =
step.kind === "choice" && step.progress ? (
<Progress {...step.progress} />
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = progress ? <TopBar>{progress}</TopBar> : null;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode;
let act: ReactNode;
switch (step.kind) {
case "consent":
lead = (
<>
<Head title={ru.flow.consentTitle} lead={[]} icon="shield" />
<div className="panel panel--scroll">
<Body texts={[...step.lead, step.title].filter(Boolean)} />
</div>
</>
);
act = (
<div className="btn-row btn-row--stack-desk">
{step.buttons.map((b, i) => (
<button
key={b.data}
type="button"
className={i === 0 ? "btn btn--primary" : "btn btn--outline"}
disabled={dialog.busy}
onClick={() => press(b)}
>
{b.label}
</button>
))}
</div>
);
break;
case "number":
lead = <Head title={step.title} lead={step.lead} icon="smile" />;
act = <NumberScale key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
case "actions":
lead = (
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{step.title || ru.meds.reminderTitle}</h1>
<Body texts={step.lead} />
</div>
);
act = <MedActions options={step.options} busy={dialog.busy} onPress={press} />;
break;
case "text":
lead = <Head title={step.title} lead={step.lead} icon="edit" />;
act = (
<FreeText key={stepKey} skip={step.skip} busy={dialog.busy} onSend={say} onSkip={press} onExit={home} />
);
break;
case "crisis":
lead = (
<>
<header className="head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="heart" size={32} />
</span>
<h1 className="h1">{ru.flow.supportTitle}</h1>
</header>
<div className="panel">
<Body texts={[step.text]} />
</div>
</>
);
act = (
<>
<div className="hotlines">
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} />
))}
</div>
<Breathing />
{step.buttons.map((b) => (
<button key={b.data} type="button" className="btn btn--outline" disabled={dialog.busy} onClick={() => press(b)}>
{b.label}
</button>
))}
<button
type="button"
className="row-card"
disabled={dialog.busy}
onClick={() => press({ label: ru.flow.openPlan, data: "safety_plan" })}
>
<Icon name="shield" />
<span className="row-card__text">
<span className="row-card__title">{ru.flow.openPlan}</span>
</span>
<Icon name="chevronRight" />
</button>
</>
);
break;
case "plan":
lead = <Head title={step.title || ru.flow.planTitle} lead={step.lead} icon="shield" />;
act = (
<>
<ul className="plan">
{step.lines.map((l) => (
<li key={l} className="plan__item">
<Icon name="shield" size={20} />
<span>{l}</span>
</li>
))}
</ul>
<button type="button" className="btn btn--primary" onClick={home}>
{ru.common.toToday}
</button>
</>
);
break;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} onHome={home} />;
break;
case "choice":
lead = <Head title={step.title} lead={step.lead} />;
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
content = (
<div className="focus" key={stepKey}>
{top}
<div className={`split split--${step.kind}${step.kind === "survey" ? ` split--in-${step.input}` : ""}`}>
<div className="split__lead">{lead}</div>
<div className="split__act">
{act}
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</div>
</div>
);
}
return (
<AppShell focus bare sos={step?.kind !== "crisis"}>
<nav className="flow-nav" aria-label={ru.flow.home}>
{/* Leaving does not send an answer or clear pending: Today can reopen this same question.
Wait for an in-flight answer before leaving so its result has reached the shared dialog. */}
<button type="button" className="btn btn--ghost btn--auto" disabled={dialog.busy} onClick={home}>
<Icon name="home" size={20} />
{ru.flow.home}
</button>
</nav>
{content}
</AppShell>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useNavigate, useParams } from "react-router-dom";
import type { TodayPlan, TodayPrescription } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useToday } from "./useToday";
const DOT: Record<string, { cls: string; icon?: IconName }> = {
taken: { cls: "wk wk--taken", icon: "check" },
missed: { cls: "wk wk--missed", icon: "x" },
pending: { cls: "wk wk--pending" },
none: { cls: "wk" },
};
export function WeekDots({ week }: { week: TodayPrescription["week"] }) {
return (
<ol className="week" aria-label="Отметки за неделю">
{week.map((w, i) => (
<li key={w.day} className="week__day">
<span className="week__label">{ru.meds.week[i]}</span>
<span className={DOT[w.status].cls} aria-label={w.status}>
{DOT[w.status].icon && <Icon name={DOT[w.status].icon!} size={14} />}
</span>
</li>
))}
</ol>
);
}
/**
* «Сообщить о побочном эффекте» — always available (spec «Модуль Лекарства»). Same engine step as the reminder's
* «Есть побочные эффекты»: `med:side_effects:<ref>`, where ref is today's dose of the drug or, without a dose row,
* today's ISO date (app/dialog/flows/meds.py).
*/
export function sendSideEffect(
dialog: ReturnType<typeof useDialog>,
navigate: (to: string) => void,
plan: TodayPlan | null,
drug: string,
label: string,
) {
if (!plan) return;
const dose = plan.doses.find((d) => d.drug === drug && d.status === "pending") ?? plan.doses.find((d) => d.drug === drug);
const ref = dose ? String(dose.id) : plan.today;
void dialog.send({ type: "button", button: `med:side_effects:${ref}` }, label).then((ok) => ok && navigate("/flow"));
}
// E4 · Моя схема приёма
export function Meds() {
const t = ru.meds;
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const ask = () =>
void dialog.send({ type: "button", button: "menu:med_question" }, t.question).then((ok) => ok && navigate("/flow"));
const reportSideEffect = (drug: string) => sendSideEffect(dialog, navigate, plan, drug, t.reportSideEffect);
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
<p className="lead">{t.lead}</p>
</header>
{plan && plan.prescriptions.length === 0 && <Notice>{t.empty}</Notice>}
<div className="grid2">
{plan?.prescriptions.map((p) => (
<article key={p.drug} className="rx">
<div className="rx__head">
<span className="tile-icon tile-icon--teal">
<Icon name="pill" />
</span>
<span>
<span className="rx__drug">{p.drug.charAt(0).toUpperCase() + p.drug.slice(1)}</span>
<span className="rx__by">
{p.dose} · {t.byDoctor}
</span>
</span>
</div>
<ul className="rx__times">
{p.times.map((time) => (
<li key={time}>
<Icon name="clock" size={18} />
<strong>{time}</strong>
<span>{t.tablet}</span>
</li>
))}
</ul>
<WeekDots week={p.week} />
<div className="rx__foot">
{p.ref_id && (
<button type="button" className="btn btn--soft btn--sm" onClick={() => navigate(`/meds/${p.ref_id}`)}>
<Icon name="info" size={18} />
{t.about}
</button>
)}
<button
type="button"
className="btn btn--ghost-red btn--sm"
disabled={dialog.busy}
onClick={() => reportSideEffect(p.drug)}
>
<Icon name="alert" size={18} />
{t.reportSideEffect}
</button>
</div>
</article>
))}
</div>
{plan && plan.prescriptions.length > 0 && <Notice>{t.onlyDoctor}</Notice>}
{plan?.has_doctor && (
<button type="button" className="btn btn--outline" onClick={ask} disabled={dialog.busy}>
<Icon name="question" />
{t.question}
</button>
)}
</div>
</AppShell>
);
}
// E1 · Напоминание о приёме (opened from «Сегодня»); answers go to the engine like the Telegram reminder buttons.
export function DoseReminder() {
const t = ru.meds;
const { id } = useParams();
const navigate = useNavigate();
const dialog = useDialog();
const { plan } = useToday();
const dose = plan?.doses.find((d) => String(d.id) === id);
const answer = (data: string, label: string) =>
void dialog.send({ type: "button", button: `${data}:${id}` }, label).then((ok) => ok && navigate("/flow"));
return (
<AppShell focus>
<div className="focus">
<TopBar onBack={() => navigate("/today")} />
<div className="split">
<div className="split__lead">
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{t.reminderTitle}</h1>
<p className="muted center">{t.reminderLead}</p>
{dose && (
<div className="reminder__time">
<Icon name="clock" />
<span>{dose.time}</span>
</div>
)}
{dose && (
<p className="reminder__drug">
{dose.drug.charAt(0).toUpperCase() + dose.drug.slice(1)}, {dose.dose}
</p>
)}
</div>
</div>
<div className="split__act">
<div className="acts">
<button type="button" className="act act--teal" disabled={dialog.busy} onClick={() => answer("med:taken", t.taken)}>
<span className="act__icon">
<Icon name="check" size={20} />
</span>
{t.taken}
</button>
<button type="button" className="act" disabled={dialog.busy} onClick={() => answer("med:skip", t.skip)}>
<span className="act__icon">
<Icon name="clock" size={20} />
</span>
{t.skip}
</button>
<button
type="button"
className="act act--red"
disabled={dialog.busy}
onClick={() => answer("med:side_effects", t.sideEffects)}
>
<span className="act__icon">
<Icon name="alert" size={20} />
</span>
{t.sideEffects}
</button>
</div>
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</div>
</div>
</AppShell>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";
import { useNavigate } from "react-router-dom";
import { AppShell, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
// I1 · Сообщения: вся переписка с помощником; быстрые ответы — кнопки последнего сообщения движка.
export function Messages() {
const t = ru.messages;
const navigate = useNavigate();
const dialog = useDialog();
const [draft, setDraft] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const quick = dialog.pending.flatMap((r) => r.buttons.flat());
useEffect(() => {
dialog.markRead();
endRef.current?.scrollIntoView({ block: "end" });
}, [dialog.entries.length]);
const send = async (e?: FormEvent) => {
e?.preventDefault();
const text = draft.trim();
if (!text || dialog.busy) return;
setDraft("");
if (!(await dialog.send({ type: "text", text }, text))) setDraft(text);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send();
}
};
return (
<AppShell>
<div className="stack chat">
<TopBar onBack={() => navigate("/today")} title={t.title} />
{dialog.entries.length === 0 && <p className="muted">{t.empty}</p>}
<ol className="bubbles" aria-live="polite">
{dialog.entries.map((e) =>
e.from === "assistant" ? (
<li key={e.id} className={`bubble bubble--bot bubble--${e.reply.kind}`}>
<span className="bubble__avatar">
<Icon name="smile" size={20} />
</span>
<p>{e.reply.text}</p>
</li>
) : (
<li key={e.id} className="bubble bubble--me">
<span className="visually-hidden">{t.you}: </span>
<p>{e.text}</p>
</li>
),
)}
</ol>
{quick.length > 0 && (
<div className="quickreplies">
{quick.map((b) => (
<button
key={b.data}
type="button"
className="qr"
disabled={dialog.busy}
onClick={() => void dialog.send({ type: "button", button: b.data }, b.label)}
>
{b.label}
</button>
))}
</div>
)}
<div ref={endRef} />
<form className="composer" onSubmit={send}>
<label htmlFor="msg" className="visually-hidden">
{t.inputLabel}
</label>
<textarea
id="msg"
rows={1}
value={draft}
placeholder={t.inputPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
maxLength={2000}
/>
<button type="submit" className="send" disabled={dialog.busy || !draft.trim()} aria-label={ru.common.send}>
<Icon name="send" size={20} />
</button>
</form>
</div>
</AppShell>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import { AppShell } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { useSession } from "../session/SessionContext";
// H1 / H2 · Профиль и мои данные
export function Profile() {
const t = ru.profile;
const navigate = useNavigate();
const { session, signOut } = useSession();
const dialog = useDialog();
const [confirm, setConfirm] = useState(false);
const [conclusion, setConclusion] = useState<string | null>(null);
const ref = useRef<HTMLDialogElement>(null);
// The preliminary conclusion of stage 1 is part of the profile until the doctor's diagnosis replaces it.
useEffect(() => {
if (!session) return;
api
.results(session.token)
.then((r) => setConclusion(r.conclusion?.title ?? null))
.catch(() => undefined);
}, [session]);
useEffect(() => {
const d = ref.current;
if (!d) return;
if (confirm && !d.open) d.showModal();
if (!confirm && d.open) d.close();
}, [confirm]);
if (!session) return null;
const anon = session.mode === "anonymous";
const revoke = async () => {
setConfirm(false);
if (await dialog.send({ type: "button", button: "consent:revoke" }, t.revoke)) navigate("/flow");
};
return (
<AppShell>
<div className="stack">
<header className="head">
<h1 className="h1">{t.title}</h1>
</header>
<div className="cols">
<section className="stack stack--sm">
<div className="row-card row-card--static">
<span className="avatar avatar--lg">
<Icon name={anon ? "lock" : "user"} />
</span>
<span className="row-card__text">
<span className="row-card__sub">{t.signInMode}</span>
<span className="row-card__title">
{anon ? t.anonymous : t.emias}
{session.displayName ? ` · ${session.displayName}` : ""}
</span>
</span>
</div>
{anon && (
<button
type="button"
className="btn btn--soft"
onClick={() => {
signOut();
navigate("/emias");
}}
>
{t.connectEmias}
</button>
)}
</section>
<section className="stack stack--sm">
<h2 className="h3">{t.data}</h2>
<button type="button" className="row-card" onClick={() => navigate("/results")}>
<span className="tile-icon tile-icon--teal">
<Icon name="clipboard" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.result}</span>
<span className="row-card__sub">{conclusion ?? t.resultNone}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card" onClick={() => navigate("/results")}>
<span className="tile-icon">
<Icon name="book" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.history}</span>
<span className="row-card__sub">{t.historySub}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card" onClick={() => navigate("/messages")}>
<span className="tile-icon">
<Icon name="message" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.messages}</span>
</span>
<Icon name="chevronRight" />
</button>
<button type="button" className="row-card row-card--danger" onClick={() => setConfirm(true)}>
<span className="tile-icon tile-icon--red">
<Icon name="shield" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.revoke}</span>
</span>
<Icon name="chevronRight" />
</button>
{anon && (
<button type="button" className="row-card" onClick={() => signOut({ wipe: true })}>
<span className="tile-icon">
<Icon name="trash" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.wipe}</span>
<span className="row-card__sub">{t.wipeNote}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
<button type="button" className="row-card row-card--danger" onClick={() => signOut()}>
<span className="tile-icon tile-icon--red">
<Icon name="logout" />
</span>
<span className="row-card__text">
<span className="row-card__title">{t.logout}</span>
</span>
<Icon name="chevronRight" />
</button>
</section>
</div>
</div>
<dialog ref={ref} className="modal" aria-labelledby="revoke-title" onClose={() => setConfirm(false)}>
<div className="modal__body">
<div className="modal__head">
<h2 id="revoke-title" className="h2">
{t.revokeTitle}
</h2>
<button type="button" className="icon-btn" onClick={() => setConfirm(false)} aria-label={t.cancel}>
<Icon name="close" />
</button>
</div>
<p className="muted">{t.revokeText}</p>
<button type="button" className="btn btn--danger" onClick={revoke}>
{t.revokeConfirm}
</button>
<button type="button" className="btn btn--soft" onClick={() => setConfirm(false)}>
{t.cancel}
</button>
</div>
</dialog>
</AppShell>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Results as ResultsData } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { Icon } from "../components/Icon";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { dayMonth } from "./useToday";
/*
* «Мои результаты»: the preliminary conclusion of stage 1 (a screening result, not a diagnosis) and the log of every
* questionnaire with its date, status and the interpretation shown when it was finished. Data come from the server
* (`/me/{token}/results`), so the log is the same on every device.
*/
export function Results() {
const t = ru.results;
const navigate = useNavigate();
const { session } = useSession();
const [data, setData] = useState<ResultsData | null>(null);
const [failed, setFailed] = useState(false);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
if (!session) return;
api
.results(session.token)
.then(setData)
.catch(() => setFailed(true));
}, [session]);
const c = data?.conclusion;
return (
<AppShell>
<div className="stack">
<TopBar onBack={() => navigate("/profile")} title={t.title} />
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
{!data && !failed && <div className="loader" aria-label="Загрузка" />}
{data && (
<section className="stack stack--sm" aria-labelledby="conclusion">
<h2 id="conclusion" className="h3">
{t.conclusion}
</h2>
{c ? (
<div className="result">
<p className="muted small">{t.conclusionDay(dayMonth(c.day))}</p>
<p className="result__title">{c.title}</p>
{c.findings.length > 0 && (
<div className="result__chips">
{c.findings.map((f) => (
<span key={f.code} className="chip chip--teal">
{f.title}
</span>
))}
</div>
)}
{c.lines.map((l) => (
<p key={l}>{l}</p>
))}
{c.modules.length > 0 && (
<p className="muted small">{c.modules.map((m) => t.modules[m as keyof typeof t.modules] ?? m).join(" · ")}</p>
)}
<p className="muted small">{c.disclaimer}</p>
</div>
) : (
<Notice>{t.noResult}</Notice>
)}
</section>
)}
{data && (
<section className="stack stack--sm" aria-labelledby="history">
<h2 id="history" className="h3">
{t.history}
</h2>
{data.history.length === 0 && <p className="muted">{t.empty}</p>}
<ul className="log">
{data.history.map((h) => {
const expanded = open === h.id;
return (
<li key={h.id}>
<button
type="button"
className="log__row"
aria-expanded={expanded}
onClick={() => setOpen(expanded ? null : h.id)}
disabled={!h.title && h.lines.length === 0}
>
<span className="log__date">{dayMonth(h.day)}</span>
<span className="log__text">
<span className="log__kind">{h.kind_title}</span>
<span className="log__sub">{h.title ?? h.status_title}</span>
</span>
{(h.title || h.lines.length > 0) && <Icon name={expanded ? "chevronDown" : "chevronRight"} />}
</button>
{expanded && (
<div className="log__body">
<p className="muted small">
{h.status_title}
{h.completed_at ? ` · ${new Date(h.completed_at).toLocaleString("ru-RU")}` : ""}
</p>
{h.lines.map((l) => (
<p key={l}>{l}</p>
))}
</div>
)}
</li>
);
})}
</ul>
</section>
)}
</div>
</AppShell>
);
}
+332
View File
@@ -0,0 +1,332 @@
import { useNavigate } from "react-router-dom";
import type { TodayDose, TodayPrescription } from "../api/types";
import { AppShell, Notice } from "../components/AppShell";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify } from "../dialog/step";
import { useSession } from "../session/SessionContext";
import { WeekDots } from "./Meds";
import { humanDate, useToday } from "./useToday";
// Spec «Модуль Лекарства»: the expected-effect card is pushed on these days of therapy.
const EFFECT_DAYS = [1, 7, 14];
function Task({
icon,
title,
sub,
hero,
chip,
onClick,
disabled,
}: {
icon: IconName;
title: string;
sub: string;
hero?: boolean;
chip?: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button type="button" className={hero ? "task task--hero" : "task"} onClick={onClick} disabled={disabled}>
<span className={hero ? "task__icon" : "task__icon task__icon--teal"}>
<Icon name={icon} />
</span>
<span className="task__text">
<span className="task__title">{title}</span>
<span className="task__sub">{sub}</span>
</span>
{chip ? <span className="chip chip--teal-solid">{chip}</span> : <Icon name="chevronRight" />}
</button>
);
}
function VisitCard({ date, onOpen }: { date: string; onOpen: () => void }) {
return (
<button type="button" className="row-card" onClick={onOpen}>
<span className="tile-icon tile-icon--blue">
<Icon name="calendar" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.visit}</span>
<span className="row-card__title">{humanDate(date)}</span>
</span>
<Icon name="chevronRight" />
</button>
);
}
function WeekCard({ rx, onOpen }: { rx: TodayPrescription; onOpen: () => void }) {
const taken = rx.week.filter((w) => w.status === "taken").length;
const counted = rx.week.filter((w) => w.status !== "none").length;
return (
<button type="button" className="card weekcard" onClick={onOpen}>
<span className="card__head">
<span className="tile-icon tile-icon--teal">
<Icon name="pill" />
</span>
<span className="row-card__text">
<span className="row-card__sub">{ru.today.week}</span>
<span className="row-card__title">{ru.today.weekTaken(taken, counted)}</span>
</span>
<Icon name="chevronRight" />
</span>
<WeekDots week={rx.week} />
</button>
);
}
// C1 / C2 · Сегодня
export function Today() {
const t = ru.today;
const navigate = useNavigate();
const { session } = useSession();
const dialog = useDialog();
const { plan, failed } = useToday();
const catalog = useCatalog();
// An unanswered engine question (reminder, check-in, scale, unfinished onboarding) waits on the focus screen.
const waiting = classify(dialog.pending);
// A paused questionnaire is not an open step: the «continue the questionnaire» card below resumes it.
const paused = waiting?.kind === "notice" && waiting.primary?.data === "intake:resume";
const hasOpenStep = waiting !== null && waiting.kind !== "idle" && waiting.kind !== "ack" && !paused;
const surveyItem = waiting?.kind === "survey" ? catalog?.items[waiting.itemId] : undefined;
const surveyForm = surveyItem ? catalog?.forms[surveyItem.form] : undefined;
const go = (button: string, label: string) =>
void dialog.send({ type: "button", button }, label).then((ok) => ok && navigate("/flow"));
// Questionnaires (stage 1 intake → stage 2 diary) replace the simple check-in once they are switched on.
const q = plan?.questionnaires;
const surveys = q?.enabled === true;
const intakeOpen = surveys && q.intake !== "complete" && q.intake !== "review";
const diaryDue = surveys && q.intake === "complete" && !q.diary_done;
const effectRx = plan?.prescriptions.find((p) => p.ref_id && p.therapy_day && EFFECT_DAYS.includes(p.therapy_day));
const pendingDoses = plan?.doses.filter((d) => d.status === "pending") ?? [];
const takenDoses = plan?.doses.filter((d) => d.status === "taken") ?? [];
const doseNow = pendingDoses.some((d) => d.time <= (plan?.now ?? "00:00"));
const allDone =
plan !== null &&
pendingDoses.length === 0 &&
plan.checkin_done &&
!hasOpenStep &&
!intakeOpen &&
!diaryDue &&
q?.intake !== "review";
const nextDose = plan?.prescriptions.flatMap((p) => p.times).sort()[0];
const noDoctor = session?.mode === "anonymous" || (plan !== null && !plan.has_doctor);
const now = plan?.now ?? "00:00";
const openDose = (d: TodayDose) => navigate(`/dose/${d.id}`);
const visit = plan?.next_visit ? <VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} /> : null;
const hasDone = takenDoses.length > 0 || plan?.checkin_done;
return (
<AppShell aside={visit}>
<div className="dash">
<header className="today-head dash__head">
<div>
<h1 className="h1">{t.title}</h1>
{plan && <p className="muted">{humanDate(plan.today)}</p>}
</div>
<button
type="button"
className="icon-btn icon-btn--bell"
onClick={() => navigate("/messages")}
aria-label={ru.nav.messages}
>
<Icon name="bell" />
{dialog.unread > 0 && <span className="dot" />}
</button>
</header>
{failed && <Notice tone="danger">{ru.common.networkError}</Notice>}
<section className="dash__main" aria-label={t.title}>
{hasOpenStep &&
(waiting?.kind === "survey" ? (
<Task
hero
icon={surveyForm?.section === "diary" ? "book" : "clipboard"}
title={surveyForm?.section === "diary" ? t.diary : t.intakeResume}
sub={t.intakeResumeHint}
chip={t.continue}
onClick={() => navigate("/flow")}
/>
) : (
<Task
hero
icon="message"
title={t.newMessage}
sub={dialog.pending[0]?.text.split("\n")[0].slice(0, 90) ?? ""}
chip={t.open}
onClick={() => navigate("/flow")}
/>
))}
{surveys && q.intake === "review" && <Notice>{t.intakeReview}</Notice>}
{effectRx && (
<button type="button" className="effect" onClick={() => navigate(`/meds/${effectRx.ref_id}`)}>
<span className="effect__day">
<span className="effect__num">{effectRx.therapy_day}</span>
<span className="effect__unit">день</span>
</span>
<span className="task__text">
<span className="task__title">{t.therapyDay(effectRx.therapy_day!)}</span>
<span className="task__sub">{t.effectHint}</span>
</span>
<Icon name="chevronRight" />
</button>
)}
{allDone ? (
<div className="alldone">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={36} />
</span>
<h2 className="h2 center">{t.allDoneTitle}</h2>
<p className="muted center">{nextDose ? t.allDoneNext(`завтра в ${nextDose}`) : t.allDoneNoMeds}</p>
{plan && plan.prescriptions.length > 0 && (
<button type="button" className="btn btn--primary btn--auto" onClick={() => navigate("/meds")}>
{t.viewMeds}
<Icon name="chevronRight" size={20} />
</button>
)}
</div>
) : (
<div className="tasks">
{pendingDoses.map((d) => (
<button
key={d.id}
type="button"
className={d.time <= now && !hasOpenStep ? "task task--hero" : "task"}
onClick={() => openDose(d)}
>
<span className="task__icon task__icon--teal">
<Icon name="pill" />
</span>
<span className="task__text">
<span className="task__title">{t.takeMed}</span>
<span className="task__sub">
{d.time} · {d.drug}, {d.dose}
</span>
</span>
{d.time <= now && <span className="chip chip--teal-solid">{t.now}</span>}
<Icon name="chevronRight" />
</button>
))}
{intakeOpen && waiting?.kind !== "survey" && (
<Task
hero={!hasOpenStep && !doseNow}
icon="clipboard"
title={t.intakeStart}
sub={q.intake === "none" ? t.intakeStartHint : t.intakeResumeHint}
chip={q.intake === "none" ? undefined : t.continue}
disabled={dialog.busy}
onClick={() =>
q.intake === "none" ? go("intake:start", t.intakeStart) : go("intake:resume", t.intakeResume)
}
/>
)}
{diaryDue && waiting?.kind !== "survey" && (
<Task
icon="book"
title={q.diary_next ? t.diaryPart[q.diary_next] : t.diary}
sub={q.diary_next ? t.diaryPartHint[q.diary_next] : t.diaryHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", q.diary_next ? t.diaryPart[q.diary_next] : t.diary)}
/>
)}
{plan && !surveys && !plan.checkin_done && (
<Task
icon="smile"
title={t.checkin}
sub={t.checkinHint}
disabled={dialog.busy}
onClick={() => go("menu:checkin", t.checkin)}
/>
)}
</div>
)}
</section>
<section className="dash__side">
{plan?.next_visit && (
<div className="only-phone">
<VisitCard date={plan.next_visit} onOpen={() => navigate("/doctor")} />
</div>
)}
{!allDone && hasDone && (
<div className="card">
<h2 className="h3">{t.done}</h2>
{takenDoses.map((d) => (
<div key={d.id} className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span>
<span className="done-row__title">{t.medTaken}</span>
<span className="done-row__sub">{t.medTakenAt(d.time)}</span>
</span>
</div>
))}
{plan?.checkin_done && (
<div className="done-row">
<span className="badge-icon badge-icon--teal">
<Icon name="check" size={16} />
</span>
<span className="done-row__title">{surveys ? t.diaryDone : t.checkinDone}</span>
</div>
)}
</div>
)}
{plan?.prescriptions[0] && <WeekCard rx={plan.prescriptions[0]} onOpen={() => navigate("/meds")} />}
{noDoctor ? (
<Notice>{t.noDoctor}</Notice>
) : (
<div className="quick">
<button
type="button"
className="quick__btn"
onClick={() => go("menu:med_question", t.medQuestion)}
disabled={dialog.busy}
>
<span className="tile-icon tile-icon--blue">
<Icon name="question" />
</span>
<span>
<span className="quick__title">{t.medQuestion}</span>
<span className="quick__sub">{t.medQuestionHint}</span>
</span>
</button>
<button
type="button"
className="quick__btn"
onClick={() => go("menu:contact_doctor", t.messageDoctor)}
disabled={dialog.busy}
>
<span className="tile-icon tile-icon--blue">
<Icon name="message" />
</span>
<span>
<span className="quick__title">{t.messageDoctor}</span>
<span className="quick__sub">{t.messageDoctorHint}</span>
</span>
</button>
</div>
)}
</section>
</div>
</AppShell>
);
}

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