Merge pull request 'feat(web): приложение пациента — двухэтапные опросы, результат скрининга, дневник по результату' (#79) from feat/rag-clinical-guidelines into dev
CI / ruff · pytest (push) Successful in 2m2s
CI / ruff · pytest (push) Successful in 2m2s
This commit was merged in pull request #79.
This commit is contained in:
@@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from app.api.schemas import (
|
||||
AlertOut,
|
||||
DemoPersona,
|
||||
DiscontinuationRisk,
|
||||
DoseOut,
|
||||
PatientCard,
|
||||
@@ -21,6 +22,11 @@ TZ = ZoneInfo("Europe/Moscow")
|
||||
TODAY = date(2026, 10, 3)
|
||||
DOCTOR_ID = "doc-smirnova"
|
||||
|
||||
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
|
||||
DEMO_PERSONAS: list[DemoPersona] = [
|
||||
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
|
||||
]
|
||||
|
||||
|
||||
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
|
||||
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Patient web app: sign-in, the patient's own plan and public reference data (`/api/v1/auth/*`, `/me/*`,
|
||||
`/crisis/resources`, `/instruments/catalog`, `/meds/reference`).
|
||||
|
||||
The web app is one more channel of the same dialog engine: after sign-in it talks to `/dialog/{token}/...`
|
||||
exactly like the web console, and consent, onboarding, scales and check-ins come from the engine.
|
||||
|
||||
Two ways in:
|
||||
- **ЕМИАС** (demo): the doctor's one-time connect code via `EmiasMock`, or a synthetic demo persona. The
|
||||
patient is linked to the attending doctor, who sees the data on the dashboard.
|
||||
- **Anonymous**: a random pseudonymous token and nothing else — no identity record, no doctor. The engine
|
||||
supports tokens without an identity record (consent is kept in the dialog state), so no personal data is
|
||||
stored at all. Crisis safety still works: the fixed crisis script does not depend on the doctor.
|
||||
|
||||
The token returned here is the session credential of the prototype. Production needs short-lived sessions
|
||||
issued by the server instead of handing out the pseudonymous token itself.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.schemas import DemoPersona
|
||||
from app.core import clock
|
||||
from app.core.instrument_catalog import Catalog, catalog
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import intake_enabled, load_runtime
|
||||
from app.core.interpretation import texts as results_texts
|
||||
from app.core.meds_reference import find_drug, indication_for, load_reference
|
||||
from app.core.safety import CrisisResource, crisis_reply
|
||||
from app.db import repo
|
||||
from app.db.clinical import DailyCheckin, Dose, Prescription
|
||||
from app.db.identity import TOKEN_LEN
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.db.session import get_clinical_session
|
||||
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["patient-web"])
|
||||
_emias = EmiasMock()
|
||||
|
||||
|
||||
class AuthOut(BaseModel):
|
||||
token: str
|
||||
mode: Literal["emias", "anonymous"]
|
||||
display_name: str | None = None # only for ЕМИАС sign-in; anonymous has none by design
|
||||
|
||||
|
||||
class EmiasCodeIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
code: str = Field(min_length=1, max_length=CODE_MAX_LEN)
|
||||
|
||||
|
||||
class AnonymousIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
focus: Literal["depression", "ptsd"] | None = None # what the person suspects; picks the module
|
||||
|
||||
|
||||
# Self-selected focus of an anonymous person → clinical module (PTSD is the veteran module, ADR-010).
|
||||
_FOCUS_MODULE = {"depression": "depression", "ptsd": "veteran"}
|
||||
|
||||
|
||||
class EmiasDemoIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
persona_token: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
async def _attach_doctor(token: str, doctor_id: str) -> None:
|
||||
async with get_clinical_session() as s:
|
||||
if await repo.get_profile(s, token) is None:
|
||||
await repo.upsert_profile(s, token=token, doctor_id=doctor_id)
|
||||
|
||||
|
||||
async def _seed_demo_persona(token: str) -> None:
|
||||
"""Synthetic clinical start for a demo persona (no `make seed` yet): profile, prescription, today's doses.
|
||||
|
||||
Idempotent: only missing pieces are created. Data mirror the dashboard fixtures, so the patient app and the
|
||||
doctor's card tell the same story.
|
||||
"""
|
||||
card = fixtures.card(token)
|
||||
item = next((p for p in fixtures.PATIENTS if p.token == token), None)
|
||||
if card is None or item is None:
|
||||
return
|
||||
now = clock.now()
|
||||
today = now.date()
|
||||
async with get_clinical_session() as s:
|
||||
if await repo.get_profile(s, token) is None:
|
||||
await repo.upsert_profile(
|
||||
s,
|
||||
token=token,
|
||||
doctor_id=fixtures.DOCTOR_ID,
|
||||
modules=list(item.modules),
|
||||
therapy_start=min((p.start_date for p in card.prescriptions if p.start_date), default=today),
|
||||
next_visit=today + timedelta(days=15),
|
||||
)
|
||||
if await s.scalar(select(Prescription.id).where(Prescription.token == token)) is not None:
|
||||
return
|
||||
for p in card.prescriptions:
|
||||
rx = Prescription(
|
||||
token=token, drug=p.drug, dose=p.dose, schedule=p.schedule, start_date=p.start_date or today
|
||||
)
|
||||
s.add(rx)
|
||||
await s.flush()
|
||||
# The dose history of the dashboard story (days before today), so the week marks match the doctor's card.
|
||||
for d in card.doses:
|
||||
if d.drug == p.drug and d.planned_at.astimezone(now.tzinfo).date() < today:
|
||||
s.add(Dose(token=token, prescription_id=rx.id, planned_at=d.planned_at, status=d.status))
|
||||
for t in p.schedule.get("times", []):
|
||||
hh, mm = (int(x) for x in t.split(":"))
|
||||
s.add(
|
||||
Dose(
|
||||
token=token, prescription_id=rx.id, planned_at=datetime.combine(today, time(hh, mm), now.tzinfo)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/emias", response_model=AuthOut)
|
||||
async def auth_emias(body: EmiasCodeIn) -> AuthOut:
|
||||
"""Sign-in through ЕМИАС (demo: one-time connect code from the doctor)."""
|
||||
person = await _emias.verify(body.code)
|
||||
if person is None:
|
||||
log.info("web auth: emias code rejected")
|
||||
raise HTTPException(401, "code not accepted")
|
||||
await _attach_doctor(person.token, person.doctor_id)
|
||||
log.info("web auth: emias tok=%s", person.token[:6])
|
||||
return AuthOut(token=person.token, mode="emias")
|
||||
|
||||
|
||||
@router.get("/patient/demo-personas", response_model=list[DemoPersona])
|
||||
async def patient_demo_personas() -> list[DemoPersona]:
|
||||
"""Synthetic ЕМИАС accounts for the patient app sign-in (the demo console has its own `/demo/personas`)."""
|
||||
return fixtures.DEMO_PERSONAS
|
||||
|
||||
|
||||
@router.post("/auth/emias/demo", response_model=AuthOut)
|
||||
async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
||||
"""Demo ЕМИАС account: signs in as one of the synthetic personas (`/patient/demo-personas`)."""
|
||||
persona = next((p for p in fixtures.DEMO_PERSONAS if p.token == body.persona_token), None)
|
||||
if persona is None:
|
||||
raise HTTPException(404, "persona not found")
|
||||
await _seed_demo_persona(persona.token)
|
||||
log.info("web auth: emias demo tok=%s", persona.token[:6])
|
||||
return AuthOut(token=persona.token, mode="emias", display_name=persona.display_name)
|
||||
|
||||
|
||||
@router.post("/auth/anonymous", response_model=AuthOut)
|
||||
async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
|
||||
"""Anonymous sign-in: a fresh random token; no identity record, no doctor.
|
||||
|
||||
The clinical side gets a doctor-less pseudonymous profile (the dialog API serves known profiles only). It holds
|
||||
no personal data — only the module of the chosen `focus`; the stage 1 screening then sets the modules itself.
|
||||
"""
|
||||
token = secrets.token_hex(TOKEN_LEN // 2)
|
||||
focus = body.focus if body is not None else None
|
||||
async with get_clinical_session() as s:
|
||||
await repo.upsert_profile(s, token=token, doctor_id="", modules=[_FOCUS_MODULE[focus]] if focus else [])
|
||||
log.info("web auth: anonymous tok=%s", token[:6])
|
||||
return AuthOut(token=token, mode="anonymous")
|
||||
|
||||
|
||||
class DoseItem(BaseModel):
|
||||
id: int
|
||||
drug: str
|
||||
dose: str
|
||||
time: str # HH:MM, patient's local time
|
||||
status: Literal["taken", "missed", "skipped", "pending"]
|
||||
|
||||
|
||||
class WeekDay(BaseModel):
|
||||
day: date
|
||||
status: Literal["taken", "missed", "pending", "none"]
|
||||
|
||||
|
||||
class PrescriptionItem(BaseModel):
|
||||
drug: str
|
||||
dose: str
|
||||
times: list[str]
|
||||
week: list[WeekDay] # Mon..Sun of the current week
|
||||
ref_id: str | None = None # entry of /meds/reference, if the drug is covered
|
||||
indication: Literal["depression", "ptsd"] | None = None
|
||||
start_date: date | None = None
|
||||
therapy_day: int | None = None # 1 on the start date (spec: M5, «ожидаемый эффект» cards on days 1/7/14)
|
||||
|
||||
|
||||
class Questionnaires(BaseModel):
|
||||
"""Questionnaire state for the home screen. Answers and scores are not exposed here."""
|
||||
|
||||
enabled: bool
|
||||
intake: Literal["none", "active", "paused", "complete", "review"]
|
||||
diary_done: bool # both parts of today's diary
|
||||
diary_next: Literal["morning", "evening"] | None = None # the part still open today
|
||||
|
||||
|
||||
class TodayOut(BaseModel):
|
||||
today: date
|
||||
now: str # HH:MM on the (demo) clock — the client never uses its own clock
|
||||
has_doctor: bool
|
||||
checkin_done: bool
|
||||
next_visit: date | None
|
||||
doses: list[DoseItem]
|
||||
prescriptions: list[PrescriptionItem]
|
||||
questionnaires: Questionnaires
|
||||
|
||||
|
||||
def _intake_state(records: list[QuestionnaireRecord]) -> Literal["none", "active", "paused", "complete", "review"]:
|
||||
intake = [r for r in records if r.kind == "intake"]
|
||||
if any(r.status == "complete" for r in intake):
|
||||
return "complete"
|
||||
if not intake:
|
||||
return "none"
|
||||
last = max(intake, key=lambda r: r.started_at).status
|
||||
if last in ("active", "paused"):
|
||||
return last
|
||||
return "review" if last in ("review_required", "interrupted") else "none"
|
||||
|
||||
|
||||
def _day_status(statuses: list[str]) -> Literal["taken", "missed", "pending", "none"]:
|
||||
if not statuses:
|
||||
return "none"
|
||||
if any(s in ("missed", "skipped") for s in statuses):
|
||||
return "missed"
|
||||
return "taken" if all(s == "taken" for s in statuses) else "pending"
|
||||
|
||||
|
||||
@router.get("/me/{token}/today", response_model=TodayOut)
|
||||
async def my_today(token: str) -> TodayOut:
|
||||
"""The patient's own plan for today (home screen) and this week's dose marks (medication screen).
|
||||
|
||||
Only the patient's own prescription data; no scores, no alerts. Anonymous tokens have no profile → empty plan.
|
||||
"""
|
||||
now = clock.now()
|
||||
today = now.date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
async with get_clinical_session() as s:
|
||||
profile = await repo.get_profile(s, token)
|
||||
prescriptions = (await s.scalars(select(Prescription).where(Prescription.token == token))).all()
|
||||
doses = (
|
||||
await s.scalars(
|
||||
select(Dose)
|
||||
.where(Dose.token == token, Dose.planned_at >= datetime.combine(week_start, time.min, now.tzinfo))
|
||||
.order_by(Dose.planned_at)
|
||||
)
|
||||
).all()
|
||||
checkin = await s.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == today))
|
||||
records = list((await s.scalars(select(QuestionnaireRecord).where(QuestionnaireRecord.token == token))).all())
|
||||
|
||||
by_rx: dict[int, Prescription] = {p.id: p for p in prescriptions}
|
||||
today_doses: list[DoseItem] = []
|
||||
marks: dict[tuple[int, date], list[str]] = defaultdict(list)
|
||||
for d in doses:
|
||||
local = d.planned_at.astimezone(now.tzinfo)
|
||||
marks[(d.prescription_id, local.date())].append(d.status)
|
||||
rx = by_rx.get(d.prescription_id)
|
||||
if local.date() == today and rx is not None:
|
||||
today_doses.append(
|
||||
DoseItem(id=d.id, drug=rx.drug, dose=rx.dose, time=local.strftime("%H:%M"), status=d.status)
|
||||
)
|
||||
week = [week_start + timedelta(days=i) for i in range(7)]
|
||||
modules = list(profile.modules or []) if profile else []
|
||||
diary_day = now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
done_parts = {r.kind for r in records if r.day == diary_day and r.status == "complete"}
|
||||
open_parts = [k.removeprefix("diary_") for k in DIARY_KINDS if k not in done_parts]
|
||||
diary_done = not open_parts
|
||||
|
||||
def item(p: Prescription) -> PrescriptionItem:
|
||||
ref = find_drug(p.drug)
|
||||
return PrescriptionItem(
|
||||
drug=p.drug,
|
||||
dose=p.dose,
|
||||
times=list((p.schedule or {}).get("times", [])),
|
||||
week=[WeekDay(day=d, status=_day_status(marks[(p.id, d)])) for d in week],
|
||||
ref_id=ref["id"] if ref else None,
|
||||
indication=indication_for(modules, ref) if ref else None,
|
||||
start_date=p.start_date,
|
||||
therapy_day=(today - p.start_date).days + 1 if p.start_date else None,
|
||||
)
|
||||
|
||||
return TodayOut(
|
||||
today=today,
|
||||
now=now.strftime("%H:%M"),
|
||||
has_doctor=profile is not None and bool(profile.doctor_id),
|
||||
checkin_done=(checkin is not None and checkin.mood is not None) or diary_done,
|
||||
next_visit=profile.next_visit if profile else None,
|
||||
doses=today_doses,
|
||||
prescriptions=[
|
||||
item(p)
|
||||
for p in prescriptions
|
||||
if (p.end_date is None or p.end_date >= today) and (p.start_date is None or p.start_date <= today)
|
||||
],
|
||||
questionnaires=Questionnaires(
|
||||
# Stage 1 needs no profile: the screening creates it (anonymous people have none before it).
|
||||
enabled=intake_enabled(),
|
||||
intake=_intake_state(records),
|
||||
diary_done=diary_done,
|
||||
diary_next=open_parts[0] if open_parts else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ResultFinding(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
|
||||
|
||||
class Conclusion(BaseModel):
|
||||
"""Preliminary conclusion of stage 1 — a screening result, not a diagnosis."""
|
||||
|
||||
day: date
|
||||
title: str
|
||||
lines: list[str]
|
||||
findings: list[ResultFinding]
|
||||
modules: list[str]
|
||||
disclaimer: str
|
||||
|
||||
|
||||
class HistoryItem(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_title: str
|
||||
status: str
|
||||
status_title: str
|
||||
day: date
|
||||
started_at: datetime
|
||||
completed_at: datetime | None
|
||||
title: str | None # interpretation shown to the patient when the form was finished
|
||||
lines: list[str]
|
||||
|
||||
|
||||
class ResultsOut(BaseModel):
|
||||
conclusion: Conclusion | None
|
||||
history: list[HistoryItem]
|
||||
|
||||
|
||||
@router.get("/me/{token}/results", response_model=ResultsOut)
|
||||
async def my_results(token: str) -> ResultsOut:
|
||||
"""The patient's own questionnaire log: every administration with its date, status and the interpretation that
|
||||
was shown when it was finished (stored with the record, so it never changes afterwards). No raw answers."""
|
||||
async with get_clinical_session() as s:
|
||||
records = list(
|
||||
(
|
||||
await s.scalars(
|
||||
select(QuestionnaireRecord)
|
||||
.where(QuestionnaireRecord.token == token)
|
||||
.order_by(QuestionnaireRecord.started_at.desc(), QuestionnaireRecord.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
t = results_texts()
|
||||
intake = next((r for r in records if r.kind == "intake" and r.status == "complete"), None)
|
||||
conclusion = None
|
||||
if intake and intake.details.get("interpretation"):
|
||||
i = intake.details["interpretation"]
|
||||
conclusion = Conclusion(
|
||||
day=intake.day,
|
||||
title=i["title"],
|
||||
lines=i["lines"],
|
||||
findings=[ResultFinding(**f) for f in i.get("findings", [])],
|
||||
modules=i.get("modules", []),
|
||||
disclaimer=i.get("disclaimer", t["disclaimer"]),
|
||||
)
|
||||
history = [
|
||||
HistoryItem(
|
||||
id=r.id,
|
||||
kind=r.kind,
|
||||
kind_title=t["kinds"].get(r.kind, r.kind),
|
||||
status=r.status,
|
||||
status_title=t["status"].get(r.status, r.status),
|
||||
day=r.day,
|
||||
started_at=r.started_at,
|
||||
completed_at=r.completed_at,
|
||||
title=(r.details.get("interpretation") or {}).get("title"),
|
||||
lines=(r.details.get("interpretation") or {}).get("lines", []),
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
return ResultsOut(conclusion=conclusion, history=history)
|
||||
|
||||
|
||||
@router.get("/crisis/resources", response_model=list[CrisisResource])
|
||||
async def crisis_resources() -> list[CrisisResource]:
|
||||
"""Help lines for the always-visible «help now» button — the same list as the fixed crisis script."""
|
||||
return crisis_reply([], "patient").resources
|
||||
|
||||
|
||||
@router.get("/instruments/catalog", response_model=Catalog)
|
||||
async def instruments_catalog() -> Catalog:
|
||||
"""Form, position and recall period of every questionnaire item, for the question screen header."""
|
||||
return catalog()
|
||||
|
||||
|
||||
@router.get("/meds/reference")
|
||||
async def meds_reference() -> dict[str, Any]:
|
||||
"""Patient drug reference (draft from the Minzdrav guidelines): expected effect, course, side effects, red flags.
|
||||
|
||||
The internal rule parameters stay on the server.
|
||||
"""
|
||||
ref = load_reference()
|
||||
return {k: v for k, v in ref.items() if k != "rules"}
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import select
|
||||
from app.api import demo as console
|
||||
from app.api import doctor_views as views
|
||||
from app.api import fixtures
|
||||
from app.api.patient_web import router as patient_web_router
|
||||
from app.api.questionnaire import router as questionnaire_router
|
||||
from app.api.schemas import (
|
||||
AckIn,
|
||||
@@ -46,6 +47,7 @@ from app.services.monitoring import refresh_status
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(questionnaire_router)
|
||||
router.include_router(patient_web_router)
|
||||
|
||||
DEFAULT_CLINIC = "clinic-demo"
|
||||
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I: the code may be typed by hand
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Read-only catalog of questionnaire items for patient clients (web app).
|
||||
|
||||
The dialog engine sends a questionnaire question as plain text plus buttons; the button payload carries the item id
|
||||
(`iq:<nonce>:<item>:<i>`, `scale:<scale>:<item>:<v>`). A client looks the item up here to show which form it belongs
|
||||
to, its position and recall period. Nothing here changes what is asked or how it is scored — the texts stay those of
|
||||
the versioned sources in config/.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts
|
||||
from app.core.scales import load_scale
|
||||
|
||||
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"]
|
||||
|
||||
# Forms whose item order depends on earlier answers: a client shows the position without «из N».
|
||||
ADAPTIVE = {"cssrs", "rus_audit", "diary", "context"}
|
||||
|
||||
|
||||
class FormInfo(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
section: Section
|
||||
recall_period: str | None = None
|
||||
instruction: str | None = None
|
||||
|
||||
|
||||
class ItemInfo(BaseModel):
|
||||
form: str
|
||||
index: int # 1-based position in the form
|
||||
total: int
|
||||
adaptive: bool
|
||||
optional: bool = False
|
||||
numeric: bool = False
|
||||
|
||||
|
||||
class DrinksTable(BaseModel):
|
||||
"""RUS-AUDIT item 2 standard-drink table, exactly as in config/texts/intake.yaml (the approved form)."""
|
||||
|
||||
title: str
|
||||
header: list[str]
|
||||
rows: list[tuple[str, list[str]]]
|
||||
note: str
|
||||
|
||||
|
||||
class Catalog(BaseModel):
|
||||
forms: dict[str, FormInfo]
|
||||
items: dict[str, ItemInfo]
|
||||
standard_drinks: DrinksTable | None
|
||||
|
||||
|
||||
def _drinks() -> DrinksTable | None:
|
||||
raw = texts().get("standard_drinks")
|
||||
if not raw:
|
||||
return None
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
title, header_line, *rest = lines
|
||||
rows = [line for line in rest if ":" in line]
|
||||
note = " ".join(line for line in rest if ":" not in line)
|
||||
return DrinksTable(
|
||||
title=title,
|
||||
header=[h.strip() for h in header_line.split("·")],
|
||||
rows=[
|
||||
(label.strip(), [c.strip() for c in cells.split("·")]) for label, cells in (r.split(":", 1) for r in rows)
|
||||
],
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def catalog() -> Catalog:
|
||||
forms: dict[str, FormInfo] = {}
|
||||
items: dict[str, ItemInfo] = {}
|
||||
|
||||
def add(form: FormInfo, ids: list[str], *, optional: set[str] = frozenset(), numeric: set[str] = frozenset()):
|
||||
forms[form.id] = form
|
||||
for i, item in enumerate(ids, 1):
|
||||
items.setdefault(
|
||||
item,
|
||||
ItemInfo(
|
||||
form=form.id,
|
||||
index=i,
|
||||
total=len(ids),
|
||||
adaptive=form.id in ADAPTIVE,
|
||||
optional=item in optional,
|
||||
numeric=item in numeric,
|
||||
),
|
||||
)
|
||||
|
||||
cssrs = load_cssrs_russian()
|
||||
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items])
|
||||
|
||||
intake_forms: dict[str, Any] = content()["forms"]
|
||||
for sid, section in (("phq9", "mood"), ("tsq", "trauma"), ("pcl5", "trauma"), ("rus_audit", "alcohol")):
|
||||
raw = intake_forms.get(sid) or {}
|
||||
scale = load_scale(sid)
|
||||
add(
|
||||
FormInfo(
|
||||
id=sid,
|
||||
title=raw.get("title") or scale.title,
|
||||
section=section,
|
||||
recall_period=raw.get("recall_period") or scale.recall_period,
|
||||
instruction=raw.get("instruction") or scale.instruction,
|
||||
),
|
||||
list(scale.item_ids),
|
||||
)
|
||||
add(FormInfo(id="trauma", title="Травматическое событие", section="trauma"), ["trauma", "recent_trauma"])
|
||||
add(FormInfo(id="context", title="Общие вопросы", section="about"), ["sex", "tbi", "psychotropics"])
|
||||
|
||||
diary = diary_config()
|
||||
core = [item for part in DIARY_PARTS for item in diary[part]]
|
||||
depression = [f"dphq_{i['id'].rsplit('_', 1)[1]}" for i in intake_forms[diary["depression"]["form"]]["items"]]
|
||||
ptsd = [f"dpcl_{i.rsplit('_', 1)[1]}" for i in diary["ptsd"]["items"]]
|
||||
add(
|
||||
FormInfo(id="diary", title="Дневник самочувствия", section="diary"),
|
||||
[i["id"] for i in core] + depression + ptsd,
|
||||
optional={i["id"] for i in core if i.get("optional")},
|
||||
numeric={i["id"] for i in core if i.get("numeric")},
|
||||
)
|
||||
|
||||
# Scales of the onboarding / scheduled sessions (`scale:<id>:...` buttons): the form header only.
|
||||
for path in sorted((get_settings().config_dir / "scales").glob("*.yaml")):
|
||||
scale = load_scale(path.stem)
|
||||
forms.setdefault(
|
||||
scale.id,
|
||||
FormInfo(
|
||||
id=scale.id,
|
||||
title=scale.title,
|
||||
section="scale",
|
||||
recall_period=scale.recall_period,
|
||||
instruction=scale.instruction,
|
||||
),
|
||||
)
|
||||
return Catalog(forms=forms, items=items, standard_drinks=_drinks())
|
||||
@@ -18,6 +18,7 @@ class Question:
|
||||
period: str = "context"
|
||||
numeric: bool = False
|
||||
optional: bool = False
|
||||
maximum: float = 24
|
||||
|
||||
|
||||
def content() -> dict:
|
||||
@@ -53,18 +54,10 @@ def cssrs_answers(answers: dict) -> dict[str, int]:
|
||||
|
||||
|
||||
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
"""Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener,
|
||||
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive.
|
||||
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py),
|
||||
so a positive answer interrupts the questionnaire wherever it comes."""
|
||||
for item in ("phq9_1", "phq9_2"):
|
||||
if item not in answers:
|
||||
return scale_question("phq9", item)
|
||||
@@ -81,6 +74,18 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
for item in load_scale("rus_audit_s").item_ids:
|
||||
if item not in answers:
|
||||
return scale_question("rus_audit_s", item)
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
if c.missing:
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == c.missing[0])
|
||||
period = texts()["periods"][item.recall_period]
|
||||
# Guidance is interviewer-facing; retained in the source and record version.
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
period + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
item.recall_period,
|
||||
)
|
||||
if sex is None and "sex" not in answers:
|
||||
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
|
||||
for key in ("tbi", "psychotropics"):
|
||||
@@ -108,34 +113,78 @@ def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Questio
|
||||
return None
|
||||
|
||||
|
||||
def diary_questions(
|
||||
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
|
||||
) -> tuple[Question, ...]:
|
||||
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
|
||||
selected = ["core"]
|
||||
detailed = [name for name in ("veteran", "depression") if name in modules]
|
||||
if len(detailed) == 2:
|
||||
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
|
||||
if "veteran" in detailed:
|
||||
selected.append("ptsd")
|
||||
if "depression" in detailed:
|
||||
selected.append("depression")
|
||||
if suicide_monitoring or "depression" in modules:
|
||||
selected.append("suicide")
|
||||
questions = []
|
||||
for section in selected:
|
||||
for item in config[section]:
|
||||
if item.get("weekly") and weekday != 0:
|
||||
continue
|
||||
questions.append(
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_week" if item.get("weekly") else "past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
def cssrs_since_contact_question(answers: dict) -> Question | None:
|
||||
"""Daily suicide-risk module of stage 2: the C-SSRS screener asked «since the last contact».
|
||||
|
||||
The official Russian Since Last Contact form is not in the repository yet (docs/clinical/README.md, C06), so the
|
||||
items of the Recent form are asked verbatim under the since-last-contact period. Behaviour since the last contact
|
||||
is recent by definition: the answer to q6 also fills q6_recent (derived), so the recent triage applies.
|
||||
"""
|
||||
c = score_cssrs_recent(cssrs_answers(answers))
|
||||
missing = [k for k in c.missing if k != "q6_recent"]
|
||||
if not missing:
|
||||
return None
|
||||
form = load_cssrs_russian()
|
||||
item = next(i for i in form.items if i.id == missing[0])
|
||||
return Question(
|
||||
"cssrs_" + item.id,
|
||||
texts()["periods"]["since_last_contact"] + "\n\n" + item.text,
|
||||
tuple((o.value, o.label) for o in form.options),
|
||||
"since_last_contact",
|
||||
)
|
||||
|
||||
|
||||
DIARY_PARTS = ("morning", "evening")
|
||||
DIARY_KINDS = ("diary_morning", "diary_evening")
|
||||
|
||||
|
||||
def diary_config() -> dict:
|
||||
return yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def diary_questions(part: str, modules: list[str], *, day_index: int = 0) -> tuple[Question, ...]:
|
||||
"""Stage 2 diary of one part of the day: the core block plus, in the evening, the modules of the profile.
|
||||
|
||||
PTSD («veteran») and depression run side by side when both are present. The depression module asks three
|
||||
PHQ-9 items a day in rotation by `day_index` (days since the first diary). Ids are prefixed (`dphq_`, `dpcl_`)
|
||||
so diary answers never mix with the full scales.
|
||||
"""
|
||||
if part not in DIARY_PARTS:
|
||||
raise ValueError("unknown diary part")
|
||||
config = diary_config()
|
||||
questions = [
|
||||
Question(
|
||||
item["id"],
|
||||
item["text"],
|
||||
tuple((o["value"], o["label"]) for o in item.get("options", [])),
|
||||
"past_day",
|
||||
item.get("numeric", False),
|
||||
item.get("optional", False),
|
||||
item.get("max", 24),
|
||||
)
|
||||
for item in config[part]
|
||||
]
|
||||
if part == "evening":
|
||||
forms = content()["forms"]
|
||||
if "depression" in modules:
|
||||
module = config["depression"]
|
||||
items = forms[module["form"]]["items"]
|
||||
per_day = module["items_per_day"]
|
||||
start = (day_index % (len(items) // per_day)) * per_day
|
||||
options = tuple((o["value"], o["label"]) for o in module["options"])
|
||||
for item in items[start : start + per_day]:
|
||||
number = item["id"].rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dphq_{number}", module["question"] + "\n\n" + item["text"], options, "today")
|
||||
)
|
||||
if "veteran" in modules:
|
||||
module = config["ptsd"]
|
||||
form = forms[module["form"]]
|
||||
by_id = {i["id"]: i["text"] for i in form["items"]}
|
||||
options = tuple((o["value"], o["label"]) for o in form["options"])
|
||||
for item_id in module["items"]:
|
||||
number = item_id.rsplit("_", 1)[1]
|
||||
questions.append(
|
||||
Question(f"dpcl_{number}", module["question"] + "\n\n" + by_id[item_id], options, "today")
|
||||
)
|
||||
)
|
||||
questions.sort(key=lambda q: q.id != "death_thoughts")
|
||||
return tuple(questions)
|
||||
|
||||
+82
-30
@@ -10,7 +10,7 @@ import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.questionnaire import Approval, load_policy
|
||||
from app.core.questionnaire import Approval, demo_mode, load_policy
|
||||
|
||||
|
||||
class RuntimePolicy(BaseModel):
|
||||
@@ -21,13 +21,20 @@ class RuntimePolicy(BaseModel):
|
||||
content_review: Approval | None = None
|
||||
contact_review: Approval | None = None
|
||||
timezone: str
|
||||
daily_hour: int = Field(ge=0, le=23)
|
||||
mood_baseline_days: int = Field(ge=2)
|
||||
mood_bad_days: int = Field(ge=1)
|
||||
mood_drop: int = Field(ge=1, le=10)
|
||||
morning_hour: int = Field(ge=0, le=23)
|
||||
evening_hour: int = Field(ge=0, le=23)
|
||||
norm_days: int = Field(ge=2)
|
||||
week_days: int = Field(ge=2)
|
||||
min_norm_values: int = Field(ge=1)
|
||||
min_week_values: int = Field(ge=1)
|
||||
mood_drop: float = Field(gt=0, le=10)
|
||||
depression_item_rise: float = Field(gt=0, le=3)
|
||||
ptsd_sum_rise: float = Field(gt=0, le=16)
|
||||
sleep_bad_days: int = Field(ge=1)
|
||||
sleep_hours_below: float = Field(gt=0, le=24)
|
||||
nightmares_at: int = Field(ge=0, le=4)
|
||||
alcohol_heavy_portions: float = Field(gt=0)
|
||||
alcohol_heavy_days: int = Field(ge=1)
|
||||
adherence_below: float = Field(gt=0, le=1)
|
||||
missed_medication_days: int = Field(ge=1)
|
||||
high_risk_silence_days: int = Field(ge=1)
|
||||
medication_days: tuple[int, ...]
|
||||
@@ -44,6 +51,8 @@ class RuntimePolicy(BaseModel):
|
||||
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
|
||||
):
|
||||
raise ValueError("all route and scale intervals must be defined")
|
||||
if self.morning_hour >= self.evening_hour:
|
||||
raise ValueError("the morning diary must come before the evening one")
|
||||
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
|
||||
raise ValueError("medication days must be nonempty and strictly increasing")
|
||||
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
|
||||
@@ -64,8 +73,17 @@ def load_runtime(path: Path | None = None) -> RuntimePolicy:
|
||||
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
|
||||
|
||||
|
||||
def demo_intake() -> bool:
|
||||
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): shows the questionnaires on synthetic data.
|
||||
|
||||
It never touches the clinical release gate in config/intake_runtime.yaml; production runs keep the forms off
|
||||
until the approvals are recorded there.
|
||||
"""
|
||||
return demo_mode()
|
||||
|
||||
|
||||
def intake_enabled() -> bool:
|
||||
return load_runtime().released and load_policy().approval is not None
|
||||
return (load_runtime().released and load_policy().approval is not None) or demo_intake()
|
||||
|
||||
|
||||
def month_before(day: date, months: int = 1) -> date:
|
||||
@@ -115,39 +133,73 @@ def due_scales(
|
||||
return tuple(sorted(due))
|
||||
|
||||
|
||||
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
|
||||
"""Consecutive calendar dates; missing data never become zero or missed doses.
|
||||
def _mean(values: list[float]) -> float:
|
||||
return sum(values) / len(values)
|
||||
|
||||
Baseline is the seven dates BEFORE the three-day deterioration window;
|
||||
every baseline value is required. Nightmares OR short sleep must hold on
|
||||
each of three nights. These candidate definitions require signed review.
|
||||
|
||||
def diary_flags(
|
||||
history: dict[date, dict],
|
||||
today: date,
|
||||
policy: RuntimePolicy | None = None,
|
||||
*,
|
||||
medication_active: bool = False,
|
||||
) -> tuple[str, ...]:
|
||||
"""Stage 2 diary flags («Этап 2», p.4): weekly means against the personal norm, never single days.
|
||||
|
||||
`history` holds the merged morning + evening answers per date. The norm is the first `norm_days` of
|
||||
observation; trends are checked only after it. Missing data never become zero or missed doses.
|
||||
"""
|
||||
p = policy or load_runtime()
|
||||
flags = []
|
||||
flags: list[str] = []
|
||||
current = history.get(today, {})
|
||||
if current.get("death_thoughts") == 1:
|
||||
flags.append("suicide_assessment_now")
|
||||
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
|
||||
baseline = [
|
||||
history.get(today - timedelta(days=i), {}).get("mood")
|
||||
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
|
||||
]
|
||||
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
|
||||
mean = sum(baseline) / len(baseline)
|
||||
if all(mean - x["mood"] >= p.mood_drop for x in recent):
|
||||
flags.append("phq9_due_mood")
|
||||
start = min(history) if history else today
|
||||
norm = [history[d] for d in sorted(history) if (d - start).days < p.norm_days]
|
||||
week_start = today - timedelta(days=p.week_days - 1)
|
||||
week = [history[d] for d in sorted(history) if week_start <= d <= today]
|
||||
trend = (week_start - start).days >= p.norm_days
|
||||
|
||||
def values(rows: list[dict], key) -> list[float]:
|
||||
return [v for r in rows if (v := key(r)) is not None]
|
||||
|
||||
def risen(key, by: float, *, min_norm: int, min_week: int, drop: bool = False) -> bool:
|
||||
before, now = values(norm, key), values(week, key)
|
||||
if not trend or len(before) < min_norm or len(now) < min_week:
|
||||
return False
|
||||
change = _mean(before) - _mean(now) if drop else _mean(now) - _mean(before)
|
||||
return change >= by
|
||||
|
||||
if risen(lambda r: r.get("mood"), p.mood_drop, min_norm=p.min_norm_values, min_week=p.min_week_values, drop=True):
|
||||
flags.append("phq9_due_mood")
|
||||
# Depression module: each PHQ-9 item comes every third day, so a weekly mean has ~2 values per item.
|
||||
for item in range(1, 10):
|
||||
if risen(lambda r, i=item: r.get(f"dphq_{i}"), p.depression_item_rise, min_norm=2, min_week=2):
|
||||
flags.append("phq9_due_depression")
|
||||
break
|
||||
# PTSD module: only the sum of the four items is interpreted, never single clusters.
|
||||
ptsd_items = ("dpcl_1", "dpcl_7", "dpcl_10", "dpcl_18")
|
||||
|
||||
def ptsd_sum(r):
|
||||
return sum(r[k] for k in ptsd_items) if all(k in r for k in ptsd_items) else None
|
||||
|
||||
if risen(ptsd_sum, p.ptsd_sum_rise, min_norm=p.min_norm_values, min_week=p.min_week_values):
|
||||
flags.append("pcl5_due_ptsd")
|
||||
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
|
||||
if all(
|
||||
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
|
||||
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
|
||||
(r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below) or r.get("sleep_quality") == 0
|
||||
for r in nights
|
||||
):
|
||||
flags.append("sleep_review")
|
||||
heavy = [r for r in week if (r.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions]
|
||||
if medication_active and (current.get("alcohol_portions") or 0) >= p.alcohol_heavy_portions:
|
||||
flags.append("alcohol_on_medication")
|
||||
if len(heavy) >= p.alcohol_heavy_days:
|
||||
flags.append("rus_audit_due_alcohol")
|
||||
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
|
||||
if all(r.get("medication") == "none" for r in doses):
|
||||
if all(r.get("medication") == "no" for r in doses):
|
||||
flags.append("medication_contact")
|
||||
if current.get("side_effects") == 1:
|
||||
taken = [r["medication"] for r in week if r.get("medication") in ("yes", "no")]
|
||||
if len(taken) >= p.min_week_values and taken.count("yes") / len(taken) < p.adherence_below:
|
||||
flags.append("adherence_review")
|
||||
if current.get("medication_reason") == "side_effects":
|
||||
flags.append("side_effects_review")
|
||||
if current.get("medication") == "partial":
|
||||
flags.append("partial_medication_review")
|
||||
return tuple(flags)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Patient-facing interpretation of questionnaire results: deterministic rules, texts from config/texts/results.yaml.
|
||||
|
||||
Stage 1 («Опросники для MVP»): screening levels per scale → a preliminary conclusion and the diary modules of stage 2.
|
||||
Stage 2 («Этап 2»): full scales on schedule (level + change: response, remission, worsening) and diary flags.
|
||||
Nothing here diagnoses: the conclusion is a screening result, the diagnosis is the doctor's (ADR-007, guardrail 3).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.intake_questions import content
|
||||
from app.core.questionnaire import IntakeDecision, IntakePolicy
|
||||
|
||||
# Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
|
||||
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
|
||||
# Remission and improvement cut-offs of the full scales («Этап 2», p.5: Kroenke 2001; Marx et al., 2022).
|
||||
PHQ9_REMISSION_BELOW = 5
|
||||
PHQ9_IMPROVED_BY = 5
|
||||
PCL5_RESPONSE_BY = 10
|
||||
PCL5_IMPROVED_BELOW = 28
|
||||
|
||||
|
||||
def texts() -> dict[str, Any]:
|
||||
return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render(interpretation: dict[str, Any]) -> str:
|
||||
"""Plain text of an interpretation for the dialog reply (title, lines, then the disclaimer if any)."""
|
||||
parts = [interpretation["title"], *interpretation.get("lines", [])]
|
||||
if interpretation.get("disclaimer"):
|
||||
parts.append(interpretation["disclaimer"])
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _alcohol_zone(totals: dict) -> int | None:
|
||||
full, short = totals.get("rus_audit") or {}, totals.get("rus_audit_s") or {}
|
||||
return full.get("zone") if full.get("complete") else short.get("zone")
|
||||
|
||||
|
||||
def intake_findings(totals: dict, answers: dict, policy: IntakePolicy) -> list[str]:
|
||||
"""Screening findings of stage 1, in the order they are shown."""
|
||||
found = []
|
||||
phq = totals.get("phq9") or {}
|
||||
if phq.get("complete"):
|
||||
if phq["total"] >= policy.phq9_specialist_at:
|
||||
found.append("depression_suspected")
|
||||
elif phq["total"] >= policy.phq9_psychologist_at:
|
||||
found.append("depression_mild")
|
||||
pcl, tsq = totals.get("pcl5") or {}, totals.get("tsq") or {}
|
||||
if pcl.get("complete") and pcl["total"] >= policy.pcl5_specialist_at:
|
||||
found.append("ptsd_suspected")
|
||||
elif answers.get("trauma") == 1 and tsq.get("complete") and tsq["total"] >= policy.tsq_expand_at:
|
||||
found.append("ptsd_signs")
|
||||
zone = _alcohol_zone(totals)
|
||||
if zone == 4:
|
||||
found.append("alcohol_high")
|
||||
elif zone in (2, 3):
|
||||
found.append("alcohol_risky")
|
||||
if (totals.get("cssrs_recent") or {}).get("risk") == "low":
|
||||
found.append("suicide_low")
|
||||
if answers.get("tbi") == 1:
|
||||
found.append("tbi")
|
||||
return found
|
||||
|
||||
|
||||
def intake_interpretation(totals: dict, answers: dict, decision: IntakeDecision, policy: IntakePolicy) -> dict:
|
||||
t = texts()
|
||||
found = intake_findings(totals, answers, policy)
|
||||
modules = [FINDING_MODULE[f] for f in found if f in FINDING_MODULE]
|
||||
head = t["intake"]["attention" if found else "ok"]
|
||||
lines = [head["text"]]
|
||||
lines += [f"{t['findings'][f]['title']}. {t['findings'][f]['text']}" for f in found]
|
||||
if decision.primary_route in t["routes"]:
|
||||
lines.append(t["routes"][decision.primary_route])
|
||||
names = ", ".join(t["modules"]["names"][m] for m in modules)
|
||||
lines.append(t["modules"]["diary"].format(modules=t["modules"]["with"].format(names=names) if names else ""))
|
||||
return {
|
||||
"kind": "intake",
|
||||
"title": head["title"],
|
||||
"lines": lines,
|
||||
"codes": found or ["ok"],
|
||||
"findings": [{"code": f, "title": t["findings"][f]["title"]} for f in found],
|
||||
"route": decision.primary_route,
|
||||
"modules": modules,
|
||||
"disclaimer": t["disclaimer"],
|
||||
}
|
||||
|
||||
|
||||
def _phq9_level(total: int) -> str:
|
||||
band = next(b for b in content()["forms"]["phq9"]["severity"] if b["min"] <= total <= b["max"])
|
||||
return band["code"]
|
||||
|
||||
|
||||
def scale_interpretation(
|
||||
kind: str,
|
||||
totals: dict,
|
||||
*,
|
||||
baseline: int | None,
|
||||
previous: int | None,
|
||||
policy: IntakePolicy,
|
||||
retriage_delta: int,
|
||||
) -> dict:
|
||||
"""A scheduled full scale: level, then the change against the start of observation and the last measurement."""
|
||||
t = texts()["scales"]
|
||||
if kind == "rus_audit_s":
|
||||
zone = _alcohol_zone(totals)
|
||||
result = totals.get("rus_audit") if (totals.get("rus_audit") or {}).get("complete") else totals["rus_audit_s"]
|
||||
level = t[kind]["levels"][f"zone_{zone}" if zone else "unknown"]
|
||||
line = t["score"].format(title=t[kind]["title"], total=result.get("total"), level=level)
|
||||
return {
|
||||
"kind": kind,
|
||||
"title": t[kind]["title"],
|
||||
"lines": [line],
|
||||
"codes": [f"zone_{zone}" if zone else "unknown"],
|
||||
}
|
||||
total = totals[kind]["total"]
|
||||
pcl5_level = "probable" if total >= policy.pcl5_specialist_at else "below"
|
||||
code = _phq9_level(total) if kind == "phq9" else pcl5_level
|
||||
lines = [t["score"].format(title=t[kind]["title"], total=total, level=t[kind]["levels"][code])]
|
||||
change = None
|
||||
if previous is not None and total - previous >= retriage_delta:
|
||||
change = "worse"
|
||||
elif baseline is not None:
|
||||
if kind == "phq9":
|
||||
if total < PHQ9_REMISSION_BELOW:
|
||||
change = "remission"
|
||||
elif baseline > 0 and total <= baseline / 2:
|
||||
change = "response"
|
||||
elif baseline - total >= PHQ9_IMPROVED_BY:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
else:
|
||||
if baseline - total >= PCL5_RESPONSE_BY:
|
||||
change = "response"
|
||||
elif total < PCL5_IMPROVED_BELOW <= baseline:
|
||||
change = "improved"
|
||||
else:
|
||||
change = "stable"
|
||||
if change:
|
||||
lines.append(t["change"][change])
|
||||
return {"kind": kind, "title": t[kind]["title"], "lines": lines, "codes": [code, *([change] if change else [])]}
|
||||
|
||||
|
||||
def diary_interpretation(part: str, flags: tuple[str, ...] | list[str]) -> dict:
|
||||
t = texts()["diary"]
|
||||
shown = [f for f in flags if f in t["flags"]]
|
||||
return {
|
||||
"kind": f"diary_{part}",
|
||||
"title": t["saved"].format(part=t["parts"][part]),
|
||||
"lines": [t["flags"][f] for f in shown],
|
||||
"codes": list(flags),
|
||||
}
|
||||
|
||||
|
||||
def cssrs_interpretation(risk: str | None) -> dict:
|
||||
t = texts()["cssrs"]
|
||||
return {
|
||||
"kind": "cssrs_since_contact",
|
||||
"title": t["low" if risk == "low" else "none"],
|
||||
"lines": [],
|
||||
"codes": [risk or "none"],
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Drug reference of the medication module (data/meds/meds_reference.json, built by scripts/build_meds_reference.py).
|
||||
|
||||
Read-only patient information collected from the Minzdrav clinical guidelines: when to expect the effect, how long
|
||||
the course lasts, common side effects, red flags. It never suggests a drug or a dose — the app only follows the
|
||||
doctor's prescription. Draft until reviewed by a psychiatrist (`status` in the file).
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
Indication = Literal["depression", "ptsd"]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_reference() -> dict[str, Any]:
|
||||
path = get_settings().data_dir / "meds" / "meds_reference.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def find_drug(name: str) -> dict[str, Any] | None:
|
||||
"""Reference entry for a prescription's drug (INN in Russian or the latin id), or None if it is not covered."""
|
||||
key = name.strip().split()[0].casefold() if name.strip() else ""
|
||||
for drug in load_reference()["drugs"]:
|
||||
if key in (drug["id"], drug["name_ru"].casefold()):
|
||||
return drug
|
||||
return None
|
||||
|
||||
|
||||
def indication_for(modules: list[str], drug: dict[str, Any]) -> Indication | None:
|
||||
"""The indication the patient is followed for, among those the drug has in the guidelines."""
|
||||
preferred: list[Indication] = ["ptsd", "depression"] if "veteran" in modules else ["depression", "ptsd"]
|
||||
return next((i for i in preferred if i in drug["indications"]), None)
|
||||
@@ -8,6 +8,7 @@ the returned status. Pending clinical policy never authorizes routine continuati
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -69,8 +70,17 @@ class IntakePolicy(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def demo_mode() -> bool:
|
||||
"""Demo-only switch (INTAKE_DEMO=1 with the virtual clock): questionnaires on synthetic data.
|
||||
|
||||
It selects `config/questionnaire_demo.yaml` (marked as NOT a clinical approval) instead of the real policy; the
|
||||
real policy and the release gate in config/intake_runtime.yaml are never touched.
|
||||
"""
|
||||
return os.environ.get("INTAKE_DEMO", "").lower() in ("1", "true", "yes") and get_settings().demo_clock
|
||||
|
||||
|
||||
def load_policy(path: Path | None = None) -> IntakePolicy:
|
||||
path = path or get_settings().config_dir / "questionnaire.yaml"
|
||||
path = path or get_settings().config_dir / ("questionnaire_demo.yaml" if demo_mode() else "questionnaire.yaml")
|
||||
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
|
||||
@@ -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 []
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
|
||||
# Stage 1 is the same for everyone (ADR-012): it does not depend on modules, which it sets itself — an anonymous
|
||||
# person has no profile at all before it.
|
||||
if after is None and intake_enabled():
|
||||
return "intake.start"
|
||||
if after in modules:
|
||||
modules = modules[modules.index(after) + 1 :]
|
||||
|
||||
+131
-57
@@ -4,9 +4,13 @@ import math
|
||||
from dataclasses import asdict, replace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from app.core import interpretation
|
||||
from app.core.intake_questions import (
|
||||
DIARY_KINDS,
|
||||
Question,
|
||||
content,
|
||||
cssrs_answers,
|
||||
cssrs_since_contact_question,
|
||||
diary_questions,
|
||||
group_answers,
|
||||
next_intake,
|
||||
@@ -17,6 +21,7 @@ from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
|
||||
from app.core.scales import load_scale
|
||||
from app.core.types import AlertLevel
|
||||
from app.db import repo
|
||||
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.dialog.crisis import crisis_open, open_crisis
|
||||
@@ -28,21 +33,23 @@ def allowed(ctx):
|
||||
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
|
||||
|
||||
|
||||
def side_effects_question():
|
||||
"""FIBSER-style tolerability question added to the full scales while a medication is active («Этап 2», p.3)."""
|
||||
return Question("side_effects", texts()["side_effects"], ((1, "Да"), (0, "Нет")), "past_week")
|
||||
|
||||
|
||||
def next_question(row):
|
||||
a = row.answers
|
||||
if row.kind == "intake":
|
||||
return next_intake(a, load_policy(), row.details.get("sex"))
|
||||
if row.kind == "diary":
|
||||
for q in diary_questions(
|
||||
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
|
||||
):
|
||||
if row.kind == "cssrs_since_contact":
|
||||
return cssrs_since_contact_question(a)
|
||||
if row.kind in DIARY_KINDS:
|
||||
part = row.kind.removeprefix("diary_")
|
||||
for q in diary_questions(part, row.details["modules"], day_index=row.details.get("day_index", 0)):
|
||||
if q.id in a:
|
||||
continue
|
||||
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
|
||||
continue
|
||||
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
|
||||
continue
|
||||
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
|
||||
if q.id == "medication_reason" and a.get("medication") != "no":
|
||||
continue
|
||||
return q
|
||||
return None
|
||||
@@ -63,11 +70,7 @@ def next_question(row):
|
||||
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
|
||||
return q
|
||||
if row.details.get("medication_active") and "side_effects" not in a:
|
||||
return next(
|
||||
q
|
||||
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
|
||||
if q.id == "side_effects"
|
||||
)
|
||||
return side_effects_question()
|
||||
return None
|
||||
|
||||
|
||||
@@ -89,7 +92,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
||||
if profile:
|
||||
profile.enhanced_monitoring = True
|
||||
flags = list(service.decision(row).flags) if row else []
|
||||
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
|
||||
if row and (row.answers.get("dphq_9", 0) > 0 or row.answers.get("phq9_9", 0) > 0):
|
||||
flags.append("cssrs_since_contact_required")
|
||||
if row:
|
||||
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
|
||||
@@ -132,7 +135,8 @@ async def advance(ctx, row):
|
||||
a = row.answers
|
||||
c = score_cssrs_recent(cssrs_answers(a))
|
||||
policy = load_policy()
|
||||
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
|
||||
# PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule).
|
||||
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high":
|
||||
return await urgent(ctx, row, code="suicide_signal")
|
||||
if c.positive and policy.cssrs_positive_policy == "stop_any":
|
||||
return await urgent(ctx, row, code="cssrs_positive")
|
||||
@@ -153,9 +157,6 @@ async def advance(ctx, row):
|
||||
ctx.now,
|
||||
params={"record_id": row.id},
|
||||
)
|
||||
if row.kind == "cssrs_since_contact":
|
||||
# Recent and Since Last Contact are different instruments. No silent substitution.
|
||||
return await manual_review(ctx, row, "cssrs_since_contact_required")
|
||||
try:
|
||||
q = next_question(row)
|
||||
# Validate preserved short answers against a newly answered full-form item 1.
|
||||
@@ -187,6 +188,8 @@ async def advance(ctx, row):
|
||||
extra = {"decision": asdict(result), "totals": service.summary(row)}
|
||||
if a.get("recent_trauma") == 1:
|
||||
extra["recent_trauma"] = True
|
||||
extra["interpretation"] = interpretation.intake_interpretation(extra["totals"], a, result, policy)
|
||||
await apply_modules(ctx, extra["interpretation"]["modules"])
|
||||
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
|
||||
await service.alert(
|
||||
ctx.session,
|
||||
@@ -196,23 +199,29 @@ async def advance(ctx, row):
|
||||
ctx.now,
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
elif row.kind == "diary":
|
||||
history = {
|
||||
r.day: r.answers
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.kind == "diary" and r.status == "complete"
|
||||
}
|
||||
history[row.day] = a
|
||||
flags = diary_flags(history, row.day)
|
||||
elif row.kind == "cssrs_since_contact":
|
||||
extra["totals"] = {"cssrs_recent": asdict(c)}
|
||||
extra["interpretation"] = interpretation.cssrs_interpretation(c.risk)
|
||||
elif row.kind in DIARY_KINDS:
|
||||
# Morning and evening answers of one date form one diary day.
|
||||
history: dict = {}
|
||||
for r in await service.records(ctx.session, ctx.token):
|
||||
if r.kind in DIARY_KINDS and r.status == "complete" and r.id != row.id:
|
||||
history.setdefault(r.day, {}).update(r.answers)
|
||||
history.setdefault(row.day, {}).update(a)
|
||||
flags = diary_flags(history, row.day, medication_active=row.details.get("medication_active", False))
|
||||
extra["flags"] = flags
|
||||
for flag in flags:
|
||||
await service.alert(
|
||||
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
|
||||
)
|
||||
if flag == "phq9_due_mood":
|
||||
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
|
||||
if flag in DIARY_SCALE_TASKS:
|
||||
sid = DIARY_SCALE_TASKS[flag]
|
||||
await service.queue_task(ctx.session, ctx.token, sid, row.id + ":" + sid, ctx.now)
|
||||
extra["interpretation"] = interpretation.diary_interpretation(row.kind.removeprefix("diary_"), flags)
|
||||
else:
|
||||
extra["totals"] = service.summary(row)
|
||||
runtime = load_runtime()
|
||||
if row.kind in ("phq9", "pcl5"):
|
||||
sid = row.kind
|
||||
total = extra["totals"][sid]["total"]
|
||||
@@ -223,27 +232,37 @@ async def advance(ctx, row):
|
||||
if total >= priority
|
||||
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
|
||||
)
|
||||
prior = next(
|
||||
(
|
||||
r
|
||||
for r in reversed(await service.records(ctx.session, ctx.token))
|
||||
if r.id != row.id
|
||||
and r.status == "complete"
|
||||
and r.version == row.version
|
||||
and r.details.get("totals", {}).get(sid, {}).get("complete")
|
||||
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
|
||||
),
|
||||
None,
|
||||
)
|
||||
earlier = [
|
||||
r
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
if r.id != row.id
|
||||
and r.status == "complete"
|
||||
and r.version == row.version
|
||||
and r.details.get("totals", {}).get(sid, {}).get("complete")
|
||||
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
|
||||
]
|
||||
prior = earlier[-1] if earlier else None
|
||||
if prior:
|
||||
change = total - prior.details["totals"][sid]["total"]
|
||||
extra["delta"] = {
|
||||
"baseline_record": prior.id,
|
||||
"value": change,
|
||||
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
|
||||
"retriage": change >= getattr(runtime, sid + "_retriage_delta"),
|
||||
}
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
sid,
|
||||
extra["totals"],
|
||||
baseline=earlier[0].details["totals"][sid]["total"] if earlier else None,
|
||||
previous=prior.details["totals"][sid]["total"] if prior else None,
|
||||
policy=policy,
|
||||
retriage_delta=getattr(runtime, sid + "_retriage_delta"),
|
||||
)
|
||||
if row.kind == "rus_audit_s" and not row.details.get("sex"):
|
||||
return await manual_review(ctx, row, "alcohol_sex_unknown")
|
||||
if row.kind == "rus_audit_s":
|
||||
extra["interpretation"] = interpretation.scale_interpretation(
|
||||
"rus_audit_s", extra["totals"], baseline=None, previous=None, policy=policy, retriage_delta=0
|
||||
)
|
||||
if extra["totals"]["rus_audit"].get("zone") == 4:
|
||||
extra["parallel_routes"] = ["narcologist"]
|
||||
retriage = extra.get("delta", {}).get("retriage", False)
|
||||
@@ -257,13 +276,48 @@ async def advance(ctx, row):
|
||||
params={"record_id": row.id, **extra},
|
||||
)
|
||||
await service.finish(ctx, row, "complete", extra=extra)
|
||||
shown = interpretation.render(extra["interpretation"]) if extra.get("interpretation") else texts()["done"]
|
||||
if row.kind == "cssrs_since_contact" and not await done_today(ctx, "diary_evening"):
|
||||
# The risk questions open the evening diary; continue straight into it.
|
||||
step = await start(ctx, "diary_evening")
|
||||
step.replies.insert(0, Reply(text=shown, kind="info"))
|
||||
return step
|
||||
from app.dialog.flows.core import menu_reply
|
||||
|
||||
replies = [menu_reply(ctx, texts()["done"])]
|
||||
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
|
||||
if alcohol_total.get("zone") in (2, 3):
|
||||
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
|
||||
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
|
||||
return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None})
|
||||
|
||||
|
||||
# Diary flags that bring a full scale forward («Этап 2», p.4).
|
||||
DIARY_SCALE_TASKS = {
|
||||
"phq9_due_mood": "phq9",
|
||||
"phq9_due_depression": "phq9",
|
||||
"pcl5_due_ptsd": "pcl5",
|
||||
"rus_audit_due_alcohol": "rus_audit_s",
|
||||
}
|
||||
|
||||
|
||||
async def apply_modules(ctx, modules):
|
||||
"""Stage 1 result → diary modules of stage 2. Without a doctor the screening defines them; a doctor's modules
|
||||
(diagnosis from ЕМИАС) are kept and only extended."""
|
||||
profile = await ctx.profile()
|
||||
if profile is None:
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id="", modules=list(modules))
|
||||
return
|
||||
current = list(profile.modules or [])
|
||||
merged = [*current, *(m for m in modules if m not in current)] if profile.doctor_id else list(modules)
|
||||
await repo.upsert_profile(ctx.session, token=ctx.token, modules=merged)
|
||||
|
||||
|
||||
def local_day(ctx):
|
||||
return ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
|
||||
|
||||
async def done_today(ctx, kind):
|
||||
day = local_day(ctx)
|
||||
return any(
|
||||
r.kind == kind and r.day == day and r.status == "complete"
|
||||
for r in await service.records(ctx.session, ctx.token)
|
||||
)
|
||||
|
||||
|
||||
async def start(ctx, kind="intake", task=None):
|
||||
@@ -288,6 +342,8 @@ async def start(ctx, kind="intake", task=None):
|
||||
if task:
|
||||
return Step()
|
||||
return Step(replies=[Reply(text=texts()["review"], kind="info")])
|
||||
# A manual start takes over the scheduled task of the same form, so the task does not stay outstanding.
|
||||
task = task or await service.outstanding_task(ctx.session, ctx.token, kind)
|
||||
row = await service.create_record(ctx, kind, task=task)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -316,14 +372,28 @@ async def checkin(ctx, event):
|
||||
existing = await service.records(ctx.session, ctx.token)
|
||||
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
|
||||
return await start(ctx)
|
||||
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
|
||||
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
|
||||
return Step(replies=[Reply(text=texts()["done"], kind="info")])
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
if risk_task.status in ("pending", "active"):
|
||||
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, "diary")
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
async def next_diary(ctx):
|
||||
"""The part of today's diary that is still open: morning first, then evening. The evening opens with the C-SSRS
|
||||
«since last contact» when the profile is under suicide-risk monitoring («Этап 2», module of suicide risk)."""
|
||||
day = local_day(ctx)
|
||||
done = {r.kind for r in await service.records(ctx.session, ctx.token) if r.day == day and r.status == "complete"}
|
||||
for kind in DIARY_KINDS:
|
||||
if kind in done:
|
||||
continue
|
||||
profile = await ctx.profile()
|
||||
if kind == "diary_evening" and profile and profile.enhanced_monitoring and "cssrs_since_contact" not in done:
|
||||
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
|
||||
return await start(ctx, "cssrs_since_contact", risk_task)
|
||||
return await start(ctx, kind)
|
||||
return Step(replies=[Reply(text=texts()["diary_done"], kind="info")])
|
||||
|
||||
|
||||
@trigger("button:diary:next")
|
||||
async def diary_button(ctx, event):
|
||||
return await next_diary(ctx)
|
||||
|
||||
|
||||
@state("intake")
|
||||
@@ -365,12 +435,16 @@ async def collect(ctx, event: Event):
|
||||
elif event.type == "text" and q.numeric:
|
||||
try:
|
||||
value = float(str(ctx.text).replace(",", "."))
|
||||
valid = math.isfinite(value) and 0 <= value <= 24
|
||||
valid = math.isfinite(value) and 0 <= value <= q.maximum
|
||||
except ValueError:
|
||||
pass
|
||||
elif event.type == "text" and q.optional:
|
||||
value, valid = str(ctx.text)[:2000], True
|
||||
if not valid:
|
||||
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
|
||||
hint = "invalid" if not q.numeric else ("numeric_portions" if q.id == "alcohol_portions" else "numeric")
|
||||
return Step(replies=[Reply(text=texts()[hint]), question_reply(row, q)])
|
||||
service.save_answer(row, q, value, ctx.now)
|
||||
if row.kind == "cssrs_since_contact" and q.id == "cssrs_q6_lifetime":
|
||||
# Behaviour since the last contact is recent by definition (see cssrs_since_contact_question).
|
||||
service.save_answer(row, replace(q, id="cssrs_q6_recent"), value, ctx.now, derived=True)
|
||||
return await advance(ctx, row)
|
||||
|
||||
@@ -47,6 +47,8 @@ from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa
|
||||
|
||||
QUESTION_STATE = "meds.question"
|
||||
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
|
||||
# A client sends this button when the person submits a free-text step without writing anything.
|
||||
NO_COMMENT = "text:skip"
|
||||
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
|
||||
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
|
||||
|
||||
@@ -379,7 +381,8 @@ def _side_effect_code(code: str) -> Any:
|
||||
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
|
||||
if code == "other":
|
||||
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,
|
||||
data={"med_se_ref": ref},
|
||||
)
|
||||
@@ -394,12 +397,15 @@ for _code in SIDE_EFFECT_CODES:
|
||||
|
||||
@state(SIDE_EFFECT_TEXT_STATE)
|
||||
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
if event.type == "button" and event.button == NO_COMMENT:
|
||||
# The description is optional: «other» side effect without words.
|
||||
return await _after_side_effects(ctx, ["other"], str(ref) if ref else None)
|
||||
if event.type != "text" or ctx.text is None:
|
||||
return await leave_to_idle(ctx, event, {"med_se_ref": None})
|
||||
facts = await ctx.gateway.extract_checkin(ctx.text)
|
||||
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
|
||||
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
|
||||
ref = ctx.data.get("med_se_ref")
|
||||
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
|
||||
if await current_prescription(ctx) is None:
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
+22
-9
@@ -15,7 +15,9 @@ from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_questions import DIARY_KINDS
|
||||
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
|
||||
from app.core.questionnaire import load_policy
|
||||
from app.db import repo
|
||||
from app.db.clinical import PatientProfile
|
||||
from app.db.intake import QuestionnaireTask
|
||||
@@ -45,16 +47,18 @@ async def plan():
|
||||
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
|
||||
if initial is None:
|
||||
continue
|
||||
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
|
||||
diaries = [r.day for r in rows if r.kind in DIARY_KINDS and r.status == "complete"]
|
||||
last_activity = max([*diaries, initial.day])
|
||||
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
|
||||
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
|
||||
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
|
||||
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
|
||||
continue
|
||||
if local.hour >= p.daily_hour and not any(
|
||||
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
|
||||
):
|
||||
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
|
||||
done = {r.kind for r in rows if r.day == local.date() and r.status == "complete"}
|
||||
# Stage 2 diary: morning block after waking, evening block at evening_hour («Этап 2», p.2).
|
||||
for kind, hour in (("diary_morning", p.morning_hour), ("diary_evening", p.evening_hour)):
|
||||
if local.hour >= hour and kind not in done:
|
||||
await service.queue_task(session, profile.token, kind, f"{kind}:{local.date()}", now)
|
||||
last = {}
|
||||
for row in rows:
|
||||
if row.status != "complete":
|
||||
@@ -82,12 +86,21 @@ async def plan():
|
||||
after_crisis=crisis_date,
|
||||
policy=p,
|
||||
)
|
||||
if scales or local.hour >= p.daily_hour:
|
||||
# Suicide-risk module: C-SSRS «since last contact» every day, only under risk monitoring.
|
||||
if profile.enhanced_monitoring and local.hour >= p.evening_hour and "cssrs_since_contact" not in done:
|
||||
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
|
||||
totals = initial.details.get("totals", {})
|
||||
modules = set(profile.modules or [])
|
||||
policy = load_policy()
|
||||
# PHQ-9 only with depression or a positive PHQ-2; PCL-5 only with PTSD or a positive TSQ («Этап 2», p.3).
|
||||
wanted = {
|
||||
"phq9": "depression" in modules or bool(totals.get("phq9", {}).get("complete")),
|
||||
"pcl5": "veteran" in modules or (totals.get("tsq", {}).get("total") or 0) >= policy.tsq_expand_at,
|
||||
"rus_audit_s": True,
|
||||
}
|
||||
for sid in scales:
|
||||
if sid == "pcl5" and initial.answers.get("trauma") != 1:
|
||||
continue
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
if wanted[sid]:
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
|
||||
|
||||
async def deliver():
|
||||
|
||||
@@ -47,6 +47,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
sex = profile.sex if profile else None
|
||||
if sex is None and prior:
|
||||
sex = prior.details.get("sex")
|
||||
day = ctx.now.astimezone(ZoneInfo(runtime.timezone)).date()
|
||||
row = QuestionnaireRecord(
|
||||
id=uuid4().hex,
|
||||
token=ctx.token,
|
||||
@@ -54,7 +55,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
version=content()["version"],
|
||||
policy_version=load_policy().version,
|
||||
started_at=ctx.now,
|
||||
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
|
||||
day=day,
|
||||
status="active",
|
||||
answers={},
|
||||
details={
|
||||
@@ -69,6 +70,8 @@ async def create_record(ctx, kind, *, task=None):
|
||||
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
|
||||
"answer_metadata": {},
|
||||
"task_id": task.id if task else None,
|
||||
# Days since stage 1: rotates the PHQ-9 items of the depression diary module (3 a day).
|
||||
"day_index": (day - prior.day).days if prior else 0,
|
||||
},
|
||||
)
|
||||
ctx.session.add(row)
|
||||
@@ -79,7 +82,7 @@ async def create_record(ctx, kind, *, task=None):
|
||||
return row
|
||||
|
||||
|
||||
def save_answer(row, question, value, now):
|
||||
def save_answer(row, question, value, now, *, derived=False):
|
||||
"""Anchor recall windows to administration, never to the day of a resumed answer."""
|
||||
day = row.day
|
||||
starts = {
|
||||
@@ -105,8 +108,8 @@ def save_answer(row, question, value, now):
|
||||
"period": question.period,
|
||||
"period_start": starts[question.period].isoformat() if question.period in starts else None,
|
||||
"period_end": day.isoformat(),
|
||||
"source": "patient",
|
||||
"derived": False,
|
||||
"source": "derived" if derived else "patient",
|
||||
"derived": derived,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -258,6 +261,16 @@ async def queue_task(session, token, kind, key, now):
|
||||
return row
|
||||
|
||||
|
||||
async def outstanding_task(session, token, kind):
|
||||
return await session.scalar(
|
||||
select(QuestionnaireTask).where(
|
||||
QuestionnaireTask.token == token,
|
||||
QuestionnaireTask.kind == kind,
|
||||
QuestionnaireTask.status.in_(("pending", "active")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def lock_patient(session, token):
|
||||
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
|
||||
# use the engine's in-process lock. Consent and answers share this transaction.
|
||||
|
||||
Reference in New Issue
Block a user