diff --git a/app/api/questionnaire.py b/app/api/questionnaire.py new file mode 100644 index 0000000..824c6f3 --- /dev/null +++ b/app/api/questionnaire.py @@ -0,0 +1,98 @@ +"""Audited questionnaire review for the synthetic prototype. + +Like the existing API, doctor_id is supplied by the caller; production must put +these routes behind authenticated staff identity. No clinical data cross into identity. +""" + +from typing import Literal + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, ConfigDict, Field + +from app.core import clock +from app.db import repo +from app.db.audit import audit +from app.db.intake import QuestionnaireRecord +from app.db.session import get_clinical_session +from app.services import questionnaire as service + +router = APIRouter(tags=["questionnaire-review"]) + + +class ReviewIn(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + doctor_id: str = Field(min_length=1) + assessment_performed: Literal[True] + decision: Literal["resume", "keep_hold"] + reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$") + + +async def require_doctor(session, token, doctor_id): + profile = await repo.get_profile(session, token) + if profile is None or profile.doctor_id != doctor_id: + raise HTTPException(404, "patient not found") + + +@router.get("/patients/{token}/questionnaires") +async def history(token: str, doctor_id: str): + async with get_clinical_session() as session: + await require_doctor(session, token, doctor_id) + rows = await service.records(session, token) + await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now()) + return [ + { + "id": r.id, + "kind": r.kind, + "status": r.status, + "version": r.version, + "policy_version": r.policy_version, + "started_at": r.started_at, + "completed_at": r.completed_at, + "answers": r.answers, + "details": r.details, + } + for r in rows + ] + + +@router.post("/patients/{token}/questionnaires/{record_id}/review") +async def review(token: str, record_id: str, body: ReviewIn): + now = clock.now() + async with get_clinical_session() as session: + await service.lock_patient(session, token) + await require_doctor(session, token, body.doctor_id) + state = await repo.get_dialog_state(session, token) + row = await session.get(QuestionnaireRecord, record_id) + if row is None or row.token != token: + raise HTTPException(404, "record not found") + if not state or state.data.get("consent") is not True: + raise HTTPException(409, "consent required") + if row.status not in ("review_required", "interrupted"): + raise HTTPException(409, "record does not require review") + if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"): + raise HTTPException(409, "close the acute episode after assessment before resuming routine work") + detail = { + "doctor_id": body.doctor_id, + "decision": body.decision, + "reference": body.reference, + "at": now.isoformat(), + } + row.details = {**row.details, "clinician_review": detail} + if body.decision == "resume": + row.status = "reviewed" + remaining = [ + r + for r in await service.records(session, token) + if r.id != row.id and r.status in ("review_required", "interrupted") + ] + if not remaining: + state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None} + await audit( + session, + actor=body.doctor_id, + action="review_questionnaire", + token=token, + details={"record_id": row.id, "decision": body.decision, "reference": body.reference}, + ts=now, + ) + return {"id": row.id, "status": row.status, "review": detail} diff --git a/app/api/routes.py b/app/api/routes.py index f32d0bb..5743a9e 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f from fastapi import APIRouter, HTTPException from app.api import fixtures +from app.api.questionnaire import router as questionnaire_router from app.api.schemas import ( AckIn, AlertOut, @@ -26,6 +27,7 @@ from app.dialog.contracts import get_engine from app.integrations.base import MemoryOutbox, integrations router = APIRouter(prefix="/api/v1") +router.include_router(questionnaire_router) # ------------------------------------------------------------------ doctor dashboard diff --git a/app/core/intake_questions.py b/app/core/intake_questions.py new file mode 100644 index 0000000..8c598a2 --- /dev/null +++ b/app/core/intake_questions.py @@ -0,0 +1,141 @@ +"""Question selection and exact versioned content, with no side effects.""" + +from dataclasses import dataclass + +import yaml + +from app.config import get_settings +from app.core.cssrs_content import load_cssrs_russian +from app.core.questionnaire import IntakePolicy, alcohol_expansion, score_alcohol, score_cssrs_recent +from app.core.scales import ScaleDef, load_scale + + +@dataclass(frozen=True) +class Question: + id: str + text: str + options: tuple[tuple[object, str], ...] = () + period: str = "context" + numeric: bool = False + optional: bool = False + + +def content() -> dict: + return yaml.safe_load((get_settings().config_dir / "instruments/intake_forms.yaml").read_text()) + + +def texts() -> dict: + return yaml.safe_load((get_settings().config_dir / "texts/intake.yaml").read_text()) + + +def scale_question(scale_id: str, item_id: str) -> Question: + forms = content()["forms"] + scale = ScaleDef.model_validate(forms[scale_id]) if scale_id in forms else load_scale(scale_id) + item = next(i for i in scale.items if i.id == item_id) + instruction = scale.instruction or "" + # The beverage table is part of the approved questionnaire source. + if item_id == "rus_audit_2": + instruction += "\n" + texts()["standard_drinks"] + return Question( + item_id, + instruction + "\n\n" + item.text, + tuple((o.value, o.label) for o in item.options), + scale.recall_period or "", + ) + + +def group_answers(answers: dict, prefix: str) -> dict[str, int]: + return {k: v for k, v in answers.items() if k.startswith(prefix + "_")} + + +def cssrs_answers(answers: dict) -> dict[str, int]: + return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")} + + +def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None: + 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, + ) + for item in ("phq9_1", "phq9_2"): + if item not in answers: + return scale_question("phq9", item) + if "trauma" not in answers: + return Question( + "trauma", content()["trauma_introduction"] + "\n\n" + content()["trauma_filter"], ((1, "Да"), (0, "Нет")) + ) + if answers["trauma"]: + for item in load_scale("tsq").item_ids: + if item not in answers: + return scale_question("tsq", item) + if "recent_trauma" not in answers: + return Question("recent_trauma", texts()["recent_trauma"], ((1, "Да"), (0, "Нет"))) + for item in load_scale("rus_audit_s").item_ids: + if item not in answers: + return scale_question("rus_audit_s", item) + if sex is None and "sex" not in answers: + return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать"))) + for key in ("tbi", "psychotropics"): + if key not in answers: + return Question(key, texts()[key], ((1, "Да"), (0, "Нет"))) + if answers["phq9_1"] + answers["phq9_2"] >= policy.phq2_expand_at: + for item in load_scale("phq9").item_ids: + if item not in answers: + return scale_question("phq9", item) + if answers["trauma"] and sum(group_answers(answers, "tsq").values()) >= policy.tsq_expand_at: + for item in load_scale("pcl5").item_ids: + if item not in answers: + return scale_question("pcl5", item) + alcohol = group_answers(answers, "rus_audit") + selected_sex = sex or (answers.get("sex") if answers.get("sex") in ("m", "f") else None) + short = score_alcohol( + {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}, short=True, sex=selected_sex + ) + if alcohol_expansion(short, policy): + for item in load_scale("rus_audit").item_ids: + if item == "rus_audit_2" and alcohol.get("rus_audit_1") == 0: + continue + if item not in answers: + return scale_question("rus_audit", item) + return None + + +def 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), + ) + ) + questions.sort(key=lambda q: q.id != "death_thoughts") + return tuple(questions) diff --git a/app/core/intake_runtime.py b/app/core/intake_runtime.py new file mode 100644 index 0000000..63112ed --- /dev/null +++ b/app/core/intake_runtime.py @@ -0,0 +1,153 @@ +"""Versioned release gate and deterministic follow-up planning.""" + +from calendar import monthrange +from datetime import date, timedelta +from pathlib import Path +from typing import Literal +from zoneinfo import ZoneInfo + +import yaml +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.config import get_settings +from app.core.questionnaire import Approval, load_policy + + +class RuntimePolicy(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + version: str + enabled: bool = False + approval: Approval | None = None + content_review: Approval | None = None + contact_review: Approval | None = None + timezone: str + 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) + sleep_bad_days: int = Field(ge=1) + sleep_hours_below: float = Field(gt=0, le=24) + nightmares_at: int = Field(ge=0, le=4) + missed_medication_days: int = Field(ge=1) + high_risk_silence_days: int = Field(ge=1) + medication_days: tuple[int, ...] + medication_repeat_days: int = Field(ge=1) + route_intervals: dict[str, dict[str, int]] + alcohol_repeat_days: int = Field(ge=1) + phq9_retriage_delta: int = Field(ge=1) + pcl5_retriage_delta: int = Field(ge=1) + + @model_validator(mode="after") + def valid(self): + ZoneInfo(self.timezone) + if set(self.route_intervals) != {"after_crisis", "specialist", "self_help"} or any( + set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values() + ): + raise ValueError("all route and scale intervals must be defined") + if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days: + raise ValueError("medication days must be nonempty and strictly increasing") + if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()): + raise ValueError("intervals must be positive") + if any( + a and a.policy_version != self.version for a in (self.approval, self.content_review, self.contact_review) + ): + raise ValueError("review must match the runtime version") + return self + + @property + def released(self) -> bool: + return self.enabled and all((self.approval, self.content_review, self.contact_review)) + + +def load_runtime(path: Path | None = None) -> RuntimePolicy: + path = path or get_settings().config_dir / "intake_runtime.yaml" + return RuntimePolicy.model_validate(yaml.safe_load(path.read_text())) + + +def intake_enabled() -> bool: + return load_runtime().released and load_policy().approval is not None + + +def month_before(day: date, months: int = 1) -> date: + year, month = divmod(day.year * 12 + day.month - 1 - months, 12) + return day.replace(year=year, month=month + 1, day=min(day.day, monthrange(year, month + 1)[1])) + + +def due_scales( + *, + today: date, + enrolled: date, + last: dict[str, date], + route: Literal["specialist", "self_help"], + medication_changed: date | None = None, + after_crisis: date | None = None, + policy: RuntimePolicy | None = None, +) -> tuple[str, ...]: + """Return each overdue form once. Therapy day zero is the recorded start/change date. + + Due dates stay due until completion. Overlapping schedules choose the earlier + date; missed ticks do not create a burst of historical questionnaires. + """ + p = policy or load_runtime() + due = set() + for scale in ("phq9", "pcl5"): + completed = last.get(scale) + dates = [(completed or enrolled) + timedelta(days=p.route_intervals[route][scale])] + if after_crisis and after_crisis <= today: + dates.append( + max(completed or after_crisis, after_crisis) + timedelta(days=p.route_intervals["after_crisis"][scale]) + ) + if medication_changed and medication_changed <= today: + for offset in p.medication_days: + milestone = medication_changed + timedelta(days=offset) + if completed is None or completed < milestone: + dates.append(milestone) + break + else: + dates.append( + max(completed or medication_changed, medication_changed + timedelta(days=p.medication_days[-1])) + + timedelta(days=p.medication_repeat_days) + ) + if min(dates) <= today: + due.add(scale) + if (last.get("rus_audit_s") or enrolled) + timedelta(days=p.alcohol_repeat_days) <= today: + due.add("rus_audit_s") + return tuple(sorted(due)) + + +def 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. + + 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. + """ + p = policy or load_runtime() + flags = [] + 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") + 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) + for r in nights + ): + flags.append("sleep_review") + 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): + flags.append("medication_contact") + if current.get("side_effects") == 1: + flags.append("side_effects_review") + if current.get("medication") == "partial": + flags.append("partial_medication_review") + return tuple(flags) diff --git a/app/core/questionnaire.py b/app/core/questionnaire.py index f12137c..b148bd6 100644 --- a/app/core/questionnaire.py +++ b/app/core/questionnaire.py @@ -41,9 +41,10 @@ class IntakePolicy(BaseModel): version: str = Field(min_length=1) approval: Approval | None = None cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending" + rus_audit_s_expand_at: int | None = Field(default=None, ge=1, le=12, strict=True) rus_audit_s_expand_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True) phq2_expand_at: int = Field(ge=1, le=6, strict=True) - pcptsd5_expand_at: int = Field(ge=1, le=5, strict=True) + tsq_expand_at: int = Field(ge=1, le=10, strict=True) phq9_psychologist_at: int = Field(ge=0, le=27, strict=True) phq9_specialist_at: int = Field(ge=0, le=27, strict=True) phq9_priority_at: int = Field(ge=0, le=27, strict=True) @@ -56,9 +57,11 @@ class IntakePolicy(BaseModel): if self.approval and ( self.approval.policy_version != self.version or self.cssrs_positive_policy == "pending" - or self.rus_audit_s_expand_from_zone is None + or (self.rus_audit_s_expand_from_zone is None and self.rus_audit_s_expand_at is None) ): raise ValueError("approval requires the matching version and resolved clinical decisions") + if self.rus_audit_s_expand_from_zone is not None and self.rus_audit_s_expand_at is not None: + raise ValueError("choose a total threshold OR a zone for alcohol expansion") if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at: raise ValueError("PHQ-9 thresholds must be ordered") if self.pcl5_specialist_at >= self.pcl5_priority_at: @@ -94,7 +97,7 @@ def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> A for item in ("rus_audit_2", "rus_audit_3"): if values.get(item, 0) != 0: raise ValueError("never drinks conflicts with positive consumption answers") - if item not in values: + if item == "rus_audit_2" and item not in values: values[item] = 0 derived.append(item) result = score(scale_id, values, sex=sex) @@ -121,6 +124,8 @@ def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | Non raise ValueError("expansion requires RUS-AUDIT-S") if not policy.approval or not result.complete or result.zone is None: return None + if policy.rus_audit_s_expand_at is not None: + return result.total >= policy.rus_audit_s_expand_at assert policy.rus_audit_s_expand_from_zone is not None return result.zone >= policy.rus_audit_s_expand_from_zone @@ -203,8 +208,8 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, zone = full.zone if full.complete else short.zone if zone is not None and zone >= 2: flags.append("alcohol_zone_ii_or_higher") - if psychotropics: - flags.append("alcohol_psychotropics") + if psychotropics and (any(v > 0 for v in alcohol.values())): + flags.append("alcohol_psychotropics") if full.zone == 4: flags.append("alcohol_zone_iv") except ValueError: @@ -217,7 +222,7 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, def evaluate_intake( *, phq9: dict[str, int], - pcptsd5: dict[str, int], + tsq: dict[str, int], pcl5: dict[str, int], alcohol: dict[str, int], cssrs: dict[str, int], @@ -269,12 +274,10 @@ def evaluate_intake( return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing) phq = score("phq9", phq9) - pc = score("pcptsd5", pcptsd5) + pc = score("tsq", tsq) pcl = score("pcl5", pcl5) full_alcohol = score_alcohol(alcohol, short=False, sex=sex) short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids} - if "rus_audit_3" in full_alcohol.derived: - short_values["rus_audit_3"] = 0 short_alcohol = score_alcohol(short_values, short=True, sex=sex) missing = list(c.missing) phq2_items = ("phq9_1", "phq9_2") @@ -285,11 +288,11 @@ def evaluate_intake( missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9) if trauma_exposure is None: missing.append("trauma_exposure") - if trauma_exposure is False and any(pcptsd5.values()): + if trauma_exposure is False and tsq: flags.append("trauma_filter_conflict") - pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.pcptsd5_expand_at + pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.tsq_expand_at if trauma_exposure is True and not pc.complete: - missing.extend(k for k in load_scale("pcptsd5").item_ids if k not in pcptsd5) + missing.extend(k for k in load_scale("tsq").item_ids if k not in tsq) if pc_positive and not pcl.complete: missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5) # Partially administered optional scales also cannot be treated as completed negatives. diff --git a/app/db/bootstrap.py b/app/db/bootstrap.py index d157c3c..9a76f84 100644 --- a/app/db/bootstrap.py +++ b/app/db/bootstrap.py @@ -4,6 +4,7 @@ The only module (besides registration code) that touches both schemas — and on """ from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata) +from app.db import intake as _intake # noqa: F401 from app.db.clinical import ClinicalBase from app.db.identity import IdentityBase from app.db.session import clinical_engine, identity_engine diff --git a/app/db/intake.py b/app/db/intake.py new file mode 100644 index 0000000..29e42e4 --- /dev/null +++ b/app/db/intake.py @@ -0,0 +1,55 @@ +"""Versioned questionnaire records, without modifying historical scale results.""" + +from datetime import date, datetime +from typing import Any + +from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.clinical import BigId, ClinicalBase +from app.db.types import JSONType, TZDateTime + + +class QuestionnaireRecord(ClinicalBase): + __tablename__ = "questionnaire_records" + id: Mapped[str] = mapped_column(String(32), primary_key=True) + token: Mapped[str] = mapped_column(String(32), index=True) + kind: Mapped[str] = mapped_column(String(32)) + version: Mapped[str] = mapped_column(String(64)) + policy_version: Mapped[str] = mapped_column(String(64)) + started_at: Mapped[datetime] = mapped_column(TZDateTime) + completed_at: Mapped[datetime | None] = mapped_column(TZDateTime) + day: Mapped[date] = mapped_column(Date) + status: Mapped[str] = mapped_column(String(32)) + answers: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict) + # Per-field time, recall period, source and derived values; totals may be null. + details: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict) + + +class QuestionnaireNotice(ClinicalBase): + __tablename__ = "questionnaire_notices" + __table_args__ = (UniqueConstraint("token", "event_key"),) + id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True) + token: Mapped[str] = mapped_column(String(32), index=True) + event_key: Mapped[str] = mapped_column(String(128)) + alert_id: Mapped[int] = mapped_column(ForeignKey("alerts.id")) + created_at: Mapped[datetime] = mapped_column(TZDateTime) + attempted_at: Mapped[datetime | None] = mapped_column(TZDateTime) + accepted_at: Mapped[datetime | None] = mapped_column(TZDateTime) + attempts: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String(32), default="pending") + error_code: Mapped[str | None] = mapped_column(String(80)) + + +class QuestionnaireTask(ClinicalBase): + __tablename__ = "questionnaire_tasks" + __table_args__ = (UniqueConstraint("token", "task_key"),) + id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True) + token: Mapped[str] = mapped_column(String(32), index=True) + task_key: Mapped[str] = mapped_column(String(128)) + kind: Mapped[str] = mapped_column(String(32)) + due_at: Mapped[datetime] = mapped_column(TZDateTime) + status: Mapped[str] = mapped_column(String(32), default="pending") + record_id: Mapped[str | None] = mapped_column(String(32)) + delivery: Mapped[list] = mapped_column(JSONType, default=list) + sent_at: Mapped[datetime | None] = mapped_column(TZDateTime) diff --git a/app/dialog/crisis.py b/app/dialog/crisis.py index 1b69f63..6f85dda 100644 --- a/app/dialog/crisis.py +++ b/app/dialog/crisis.py @@ -4,7 +4,7 @@ Decisions: * The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per open crisis. "Open" = `data["crisis"]["open"]` — set when the crisis starts, cleared only by the doctor's acknowledgement (`doctor_ack`), so a patient repeating the phrase does not get the same long script again: - they get a short fixed "я рядом, врач уже уведомлён" + emergency resources instead. + they get a short fixed support message and emergency resources instead. * Entering the crisis drops unfinished scale sessions (their answered items are already stored item by item): nothing else is asked while the patient is in crisis, except the C-SSRS screen. * Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message — diff --git a/app/dialog/engine.py b/app/dialog/engine.py index 6ddbdd2..e959d6c 100644 --- a/app/dialog/engine.py +++ b/app/dialog/engine.py @@ -22,15 +22,18 @@ Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3): Logs carry only the token, states and event codes (guardrail #8). """ +import asyncio import logging from dataclasses import dataclass, field from datetime import datetime from typing import Any +from weakref import WeakValueDictionary from sqlalchemy.exc import InterfaceError, OperationalError from sqlalchemy.ext.asyncio import AsyncSession from app.core import clock, safety +from app.core.intake_runtime import intake_enabled from app.core.privacy import scrub from app.core.safety import SafetyResult, crisis_reply from app.core.types import RiskLevel, ScrubbedText @@ -45,12 +48,14 @@ from app.dialog.flows.core import close_crisis, revoke from app.dialog.fsm import NO_CONSENT_STATES, RESTING_STATES, Ctx, Step, apply_data, enter_hook_for, handler_for from app.dialog.texts import DialogTexts, load_dialog_texts from app.llm.gateway import LLMGateway, get_gateway +from app.services import questionnaire as intake_service from app.services.contracts import MonitoringService, get_monitoring log = logging.getLogger(__name__) # Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request. MAX_ENTER_CHAIN = 8 +_patient_locks = WeakValueDictionary() # DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface). _DB_DOWN = (OSError, OperationalError, InterfaceError) @@ -76,8 +81,13 @@ class Engine: async def handle(self, token: str, event: Event) -> list[Reply]: texts = load_dialog_texts() try: - async with get_clinical_session() as session: - return await self._handle(token, event, session, texts) + lock = _patient_locks.setdefault(token, asyncio.Lock()) + async with lock: + async with get_clinical_session() as session: + await intake_service.lock_patient(session, token) + replies = await self._handle(token, event, session, texts) + await intake_service.dispatch_notices(token) + return replies except _DB_DOWN as exc: log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__) return _degraded(event, texts) @@ -117,6 +127,13 @@ class Engine: session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state ) await self._save_state(ctx) + task_id = event.params.get("questionnaire_task") + if event.type == "system" and task_id and run.replies: + from app.db.intake import QuestionnaireTask + + task = await session.get(QuestionnaireTask, task_id) + if task and task.token == token: + task.delivery = [r.model_dump() for r in run.replies] log.info( "dialog token=%s event=%s code=%s state=%s->%s replies=%d", token, @@ -143,6 +160,12 @@ class Engine: if event.type == "button": if consented and event.button == "consent:revoke": return await revoke(ctx) + if consented and event.button == "intake:help": + from app.db.intake import QuestionnaireRecord + from app.dialog.flows.intake import urgent + + row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", "")) + return await urgent(ctx, row) if consented: await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client return await self._dispatch(ctx, event) @@ -153,6 +176,14 @@ class Engine: if not consented: return Step() # nothing is pushed to people without consent if event.system == "scale_due" and event.params.get("scale"): + if intake_enabled() and ctx.data.get("adult") is True: + scale = str(event.params["scale"]) + kind = "cssrs_since_contact" if scale in ("cssrs_screen", "cssrs") else scale + if kind in ("phq9", "pcl5", "rus_audit_s", "cssrs_since_contact"): + await intake_service.queue_task( + ctx.session, ctx.token, kind, f"external:{kind}:{ctx.now.date()}", ctx.now + ) + return Step() scheduled = bool(event.params.get("scheduled", True)) pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled) return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step @@ -181,11 +212,29 @@ class Engine: if result.level == RiskLevel.ACUTE: handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script" log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled) - step = open_crisis(ctx, result.categories, source=f"safety:{result.source}") + if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True): + from app.db.intake import QuestionnaireRecord + from app.dialog.flows.intake import urgent + + row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", "")) + step = await urgent(ctx, row, code="safety_acute", categories=result.categories) + else: + step = open_crisis(ctx, result.categories, source=f"safety:{result.source}") step.data["safety_events"] = self._safety_events(ctx, result, handled) step.notify_monitoring = True # lane B escalates to the doctor on call return step + if result.level == RiskLevel.POSSIBLE and ( + ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True) + ): + from app.db.intake import QuestionnaireRecord + from app.dialog.flows.intake import manual_review + + row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", "")) + if row is None: + row = await intake_service.create_record(ctx, "cssrs_since_contact") + return await manual_review(ctx, row, "cssrs_since_contact_required") + if result.level == RiskLevel.POSSIBLE: screen = ctx.texts.settings.risk_screen_scale if self._can_screen(ctx, screen): @@ -252,6 +301,8 @@ class Engine: async def _start_pending(self, ctx: Ctx) -> Step: """Start a queued scale when nothing else is being asked. In crisis only the risk screen may run.""" + if intake_enabled(): + return Step() pending = list(ctx.data.get("pending_scales") or []) if not pending or ctx.state not in RESTING_STATES: return Step() diff --git a/app/dialog/flows/core.py b/app/dialog/flows/core.py index 5f4e09a..2d3ec47 100644 --- a/app/dialog/flows/core.py +++ b/app/dialog/flows/core.py @@ -42,6 +42,20 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply: [Button(label=b.contact_doctor, data="menu:contact_doctor")], [Button(label=b.revoke, data="consent:revoke")], ] + from app.core.intake_questions import texts + from app.core.intake_runtime import intake_enabled + + if intake_enabled(): + buttons.insert(0, [Button(label=texts()["help"], data="intake:help")]) + buttons.insert( + 0, + [ + Button( + label=texts()["resume"] if ctx.data.get("questionnaire_record") else texts()["start"], + data="intake:resume" if ctx.data.get("questionnaire_record") else "intake:start", + ) + ], + ) return Reply(text=text or ctx.texts.idle.menu_prompt, buttons=buttons) @@ -53,6 +67,10 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str: profile (after `after`, if given) that has such a state registered; `"idle"` when none is left.""" profile = await ctx.profile() modules = list(profile.modules) if profile is not None and profile.modules else [] + from app.core.intake_runtime import intake_enabled + + if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")): + return "intake.start" if after in modules: modules = modules[modules.index(after) + 1 :] for module in modules: @@ -275,6 +293,8 @@ async def _reach_out(ctx: Ctx, event: Event) -> Step: def close_crisis(ctx: Ctx) -> Step: """Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards.""" + if not (ctx.data.get("crisis") or {}).get("open") and ctx.state != "crisis": + return Step() crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()} scales = { sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s @@ -311,7 +331,11 @@ async def revoke(ctx: Ctx) -> Step: """ async with get_identity_session() as s: await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now) + from app.services.questionnaire import cancel_pending + + await cancel_pending(ctx.session, ctx.token) data = { + "questionnaire_record": None, "consent": False, "revoked_at": ctx.now.isoformat(), "delete_requested": True, diff --git a/app/dialog/flows/intake.py b/app/dialog/flows/intake.py new file mode 100644 index 0000000..80c801b --- /dev/null +++ b/app/dialog/flows/intake.py @@ -0,0 +1,376 @@ +"""Versioned, resumable questionnaires. Disabled until the clinical release gate opens.""" + +import math +from dataclasses import asdict, replace +from zoneinfo import ZoneInfo + +from app.core.intake_questions import ( + content, + cssrs_answers, + diary_questions, + group_answers, + next_intake, + scale_question, + texts, +) +from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime +from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent +from app.core.scales import load_scale +from app.core.types import AlertLevel +from app.db.intake import QuestionnaireRecord, QuestionnaireTask +from app.dialog.contracts import Button, Event, Reply +from app.dialog.crisis import crisis_open, open_crisis +from app.dialog.fsm import Step, on_enter, state, trigger +from app.services import questionnaire as service + + +def allowed(ctx): + return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled() + + +def 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 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: + continue + return q + return None + if row.kind in ("phq9", "pcl5", "rus_audit_s"): + sid = row.kind + if sid == "rus_audit_s": + short = score_alcohol( + {k: a[k] for k in load_scale(sid).item_ids if k in a}, short=True, sex=row.details.get("sex") + ) + if alcohol_expansion(short, load_policy()): + sid = "rus_audit" + for item in load_scale(sid).item_ids: + if item == "rus_audit_2" and a.get("rus_audit_1") == 0: + continue + if item not in a: + q = scale_question(sid, item) + if sid == "pcl5" and row.details["weekly_pcl"]: + q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week") + return q + if row.details.get("medication_active") and "side_effects" not in a: + return next( + q + for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday()) + if q.id == "side_effects" + ) + return None + + +def question_reply(row, q): + t = texts() + # Nonce + item + option index rejects old/replayed callbacks without exposing raw answers. + prefix = f"iq:{row.id[:12]}:{q.id}:" + buttons = [[Button(label=label, data=prefix + str(i))] for i, (_, label) in enumerate(q.options)] + if q.optional: + buttons.append([Button(label=t["comment_skip"], data=prefix + "skip")]) + buttons.extend([[Button(label=t["help"], data="intake:help")], [Button(label=t["pause"], data="intake:pause")]]) + return Reply(text=q.text + "\n\n" + t["as_of"].format(day=row.day.isoformat()), buttons=buttons, kind="question") + + +async def urgent(ctx, row=None, *, code="help_now", categories=None): + if crisis_open(ctx.data): + return open_crisis(ctx, categories or [], source="questionnaire:" + code) + profile = await ctx.profile() + if profile: + profile.enhanced_monitoring = True + flags = list(service.decision(row).flags) if row else [] + if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0): + flags.append("cssrs_since_contact_required") + if row: + await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags}) + await service.alert( + ctx.session, + ctx.token, + f"{row.id if row else ctx.now.isoformat()}:{code}", + code, + ctx.now, + level=AlertLevel.ACUTE, + params={"record_id": row.id if row else None, "flags": flags}, + ) + step = open_crisis(ctx, categories or [], source="questionnaire:" + code) + step.data["questionnaire_record"] = None + step.data["intake_review_required"] = True + for reply in step.replies: + reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")]) + return step + + +async def manual_review(ctx, row, code): + await service.finish(ctx, row, "review_required", extra={"stop_reason": code}) + await service.alert( + ctx.session, ctx.token, f"{row.id}:{code}", code, ctx.now, level=AlertLevel.HIGH, params={"record_id": row.id} + ) + return Step( + replies=[ + Reply( + text=texts()["missing_followup"] if code == "cssrs_since_contact_required" else texts()["review"], + buttons=[[Button(label=texts()["help"], data="intake:help")]], + kind="info", + ) + ], + next_state="idle", + data={"questionnaire_record": None, "intake_review_required": True}, + ) + + +async def advance(ctx, row): + a = row.answers + c = score_cssrs_recent(cssrs_answers(a)) + policy = load_policy() + if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high": + return await urgent(ctx, row, code="suicide_signal") + if c.positive and policy.cssrs_positive_policy == "stop_any": + return await urgent(ctx, row, code="cssrs_positive") + if c.conflicts: + return await manual_review(ctx, row, "cssrs_conflict") + # Finish the risk questions before any routine question; a moderate result stops here. + if c.complete and c.risk == "moderate": + return await manual_review(ctx, row, "urgent_psychiatrist") + if c.complete and c.risk == "low": + profile = await ctx.profile() + if profile: + profile.enhanced_monitoring = True + await service.alert( + ctx.session, + ctx.token, + row.id + ":cssrs_low", + "cssrs_low_safety_plan", + ctx.now, + params={"record_id": row.id}, + ) + 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. + score_alcohol(group_answers(a, "rus_audit"), short=False, sex=row.details.get("sex")) + except ValueError: + return await manual_review(ctx, row, "answer_conflict") + if q: + replies = [question_reply(row, q)] + if c.complete and c.risk == "low" and not row.details.get("plan_shown"): + from app.dialog.actions import safety_plan_reply + + replies.insert(0, await safety_plan_reply(ctx)) + row.details = {**row.details, "plan_shown": True} + return Step(replies=replies, next_state="intake.collect", data={"questionnaire_record": row.id}) + extra = {} + if a.get("side_effects") == 1: + await service.alert( + ctx.session, + ctx.token, + row.id + ":side_effects_review", + "side_effects_review", + ctx.now, + params={"record_id": row.id}, + ) + if row.kind == "intake": + result = service.decision(row) + if result.status != "ready": + return await manual_review(ctx, row, "intake_incomplete_review") + extra = {"decision": asdict(result), "totals": service.summary(row)} + if a.get("recent_trauma") == 1: + extra["recent_trauma"] = True + if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"): + await service.alert( + ctx.session, + ctx.token, + row.id + ":route", + "intake_route_review", + ctx.now, + params={"record_id": row.id, **extra}, + ) + elif row.kind == "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) + 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) + else: + extra["totals"] = service.summary(row) + if row.kind in ("phq9", "pcl5"): + sid = row.kind + total = extra["totals"][sid]["total"] + priority = getattr(policy, sid + "_priority_at") + specialist = getattr(policy, sid + "_specialist_at") + extra["route"] = ( + "priority_psychiatrist" + if total >= priority + else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician") + ) + 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, + ) + 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"), + } + if row.kind == "rus_audit_s" and not row.details.get("sex"): + return await manual_review(ctx, row, "alcohol_sex_unknown") + if extra["totals"]["rus_audit"].get("zone") == 4: + extra["parallel_routes"] = ["narcologist"] + retriage = extra.get("delta", {}).get("retriage", False) + await service.alert( + ctx.session, + ctx.token, + row.id + ":followup", + "followup_retriage_required" if retriage else "followup_review", + ctx.now, + level=AlertLevel.HIGH if retriage or extra.get("route") == "priority_psychiatrist" else AlertLevel.MODERATE, + params={"record_id": row.id, **extra}, + ) + await service.finish(ctx, row, "complete", extra=extra) + 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}) + + +async def start(ctx, kind="intake", task=None): + if not allowed(ctx) or crisis_open(ctx.data): + if task: + return Step() + return Step(replies=[Reply(text=texts()["unavailable"], kind="info")]) + existing = await service.records(ctx.session, ctx.token) + active = next((r for r in reversed(existing) if r.status in ("active", "paused")), None) + if active: + if task and task.record_id != active.id: + return Step() + if ( + active.policy_version != load_policy().version + or active.version != content()["version"] + or active.details.get("runtime_version") != load_runtime().version + ): + return await manual_review(ctx, active, "questionnaire_version_changed") + active.status = "active" + return await advance(ctx, active) + if ctx.data.get("intake_review_required") and kind != "cssrs_since_contact": + if task: + return Step() + return Step(replies=[Reply(text=texts()["review"], kind="info")]) + row = await service.create_record(ctx, kind, task=task) + return await advance(ctx, row) + + +@on_enter("intake.start") +async def enter(ctx): + return await start(ctx) + + +@trigger("button:intake:start") +@trigger("button:intake:resume") +async def start_button(ctx, event): + return await start(ctx) + + +@trigger("button:menu:checkin", replace=True) +@trigger("system:daily_checkin", replace=True) +async def checkin(ctx, event): + if not intake_enabled(): + return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)]) + if event.params.get("questionnaire_task"): + task = await ctx.session.get(QuestionnaireTask, event.params["questionnaire_task"]) + if task is None or task.token != ctx.token or task.status not in ("pending", "active"): + return Step() + return await start(ctx, task.kind, task) + existing = await service.records(ctx.session, ctx.token) + if not any(r.kind == "intake" and r.status == "complete" for r in existing): + return await start(ctx) + 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") + + +@state("intake") +async def collect(ctx, event: Event): + if not allowed(ctx): + return Step(replies=[Reply(text=texts()["unavailable"], kind="info")], next_state="idle") + row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", "")) + if row is None or row.token != ctx.token or row.status != "active": + return Step(next_state="idle") + if ( + row.policy_version != load_policy().version + or row.version != content()["version"] + or row.details.get("runtime_version") != load_runtime().version + ): + return await manual_review(ctx, row, "questionnaire_version_changed") + if event.type == "system": + task_id = event.params.get("questionnaire_task") + if task_id and row.details.get("task_id") == task_id: + return await advance(ctx, row) + return Step() + if event.button == "intake:pause": + row.status = "paused" + return Step( + replies=[Reply(text=texts()["paused"], buttons=[[Button(label=texts()["resume"], data="intake:resume")]])], + next_state="idle", + ) + q = next_question(row) + if q is None: + return await advance(ctx, row) + prefix = f"iq:{row.id[:12]}:{q.id}:" + value = None + valid = False + if event.type == "button" and (event.button or "").startswith(prefix): + index = event.button[len(prefix) :] + if index == "skip" and q.optional: + valid = True + elif index.isdigit() and int(index) < len(q.options): + value, valid = q.options[int(index)][0], True + elif event.type == "text" and q.numeric: + try: + value = float(str(ctx.text).replace(",", ".")) + valid = math.isfinite(value) and 0 <= value <= 24 + 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)]) + service.save_answer(row, q, value, ctx.now) + return await advance(ctx, row) diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index 7e6f7b5..ed8cb21 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -1,13 +1,176 @@ -"""Scheduler on the (virtual) demo clock — stub until #13. Called from app.main lifespan.""" +"""Questionnaire jobs on the demo clock; clinical release disabled by default. + +One scheduler per database. PostgreSQL's advisory transaction lock serializes +workers; an asyncio lock does the same within a SQLite demonstration process. +Deliveries are at least once: the channel protocol offers no idempotency key. +""" + +import asyncio +import logging +from contextlib import suppress +from datetime import datetime +from zoneinfo import ZoneInfo + +from sqlalchemy import select, text + +from app.core import clock +from app.core.intake_runtime import due_scales, intake_enabled, load_runtime +from app.db import repo +from app.db.clinical import PatientProfile +from app.db.intake import QuestionnaireTask +from app.db.session import get_clinical_session +from app.dialog.contracts import Event, Reply, get_engine +from app.integrations.base import integrations +from app.services import questionnaire as service + +log = logging.getLogger(__name__) +_worker = None +_lock = asyncio.Lock() -async def start() -> None: - """Start periodic ticking (#13).""" +async def plan(): + now = clock.now() + p = load_runtime() + local = now.astimezone(ZoneInfo(p.timezone)) + async with get_clinical_session() as session: + profiles = (await session.scalars(select(PatientProfile))).all() + for profile in profiles: + state = await repo.get_dialog_state(session, profile.token) + if not state or state.data.get("consent") is not True or state.data.get("adult") is not True: + continue + rows = await service.records(session, profile.token) + initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) + if initial is None: + continue + last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [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) + last = {} + for row in rows: + if row.status != "complete": + continue + for sid in ("phq9", "pcl5", "rus_audit_s"): + if row.details.get("totals", {}).get(sid, {}).get("complete"): + last[sid] = row.completed_at.astimezone(ZoneInfo(p.timezone)).date() + route = initial.details["decision"]["primary_route"] + prescriptions = await integrations.prescriptions.get_prescriptions(profile.token) + changes = [ + v.start_date + for v in prescriptions + if v.start_date and v.start_date <= local.date() and (v.end_date is None or v.end_date >= local.date()) + ] + if profile.therapy_start and profile.therapy_start <= local.date(): + changes.append(profile.therapy_start) + closed = (state.data.get("crisis") or {}).get("closed_at") + crisis_date = datetime.fromisoformat(closed).astimezone(ZoneInfo(p.timezone)).date() if closed else None + scales = due_scales( + today=local.date(), + enrolled=initial.day, + last=last, + route="self_help" if route == "self_help" else "specialist", + medication_changed=max(changes) if changes else None, + after_crisis=crisis_date, + policy=p, + ) + if scales or local.hour >= p.daily_hour: + await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now) + 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) -async def stop() -> None: - """Stop the scheduler (#13).""" +async def deliver(): + async with get_clinical_session() as session: + task_ids = list( + ( + await session.scalars( + select(QuestionnaireTask.id) + .where( + QuestionnaireTask.sent_at.is_(None), + QuestionnaireTask.status != "cancelled", + QuestionnaireTask.due_at <= clock.now(), + ) + .order_by((QuestionnaireTask.kind != "cssrs_since_contact"), QuestionnaireTask.id) + ) + ).all() + ) + for task_id in task_ids: + async with get_clinical_session() as session: + task = await session.get(QuestionnaireTask, task_id) + state = await repo.get_dialog_state(session, task.token) + if not state or state.data.get("consent") is not True or state.data.get("adult") is not True: + task.status, task.delivery = "cancelled", [] + continue + if (state.data.get("crisis") or {}).get("open"): + continue + if task.status == "done" and not task.delivery: + continue + token = task.token + payload = task.delivery + if not payload and state.state not in ("idle", "intake.collect"): + continue + if not payload: + await get_engine().handle( + token, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) + ) + # Serialize delivery with withdrawal of consent and user answers. + async with get_clinical_session() as session: + await service.lock_patient(session, token) + state = await repo.get_dialog_state(session, token) + task = await session.get(QuestionnaireTask, task_id) + if not state or state.data.get("consent") is not True or not task.delivery: + continue + try: + await asyncio.wait_for( + integrations.outbox.push(token, [Reply.model_validate(r) for r in task.delivery]), timeout=10 + ) + except Exception as exc: + log.warning("questionnaire outbox error=%s", type(exc).__name__) + else: + task.sent_at = clock.now() -async def run_until_now() -> None: - """Replay all due events up to `app.core.clock.now()` in order, idempotently (#13).""" +async def run_until_now(): + async with _lock, get_clinical_session() as guard: + if guard.bind.dialect.name == "postgresql" and not await guard.scalar( + text("SELECT pg_try_advisory_xact_lock(520053)") + ): + return + await service.dispatch_notices() + if not intake_enabled(): + return + await plan() + await deliver() + await service.dispatch_notices() + + +async def _run(): + while True: + try: + await run_until_now() + except Exception as exc: + log.error("questionnaire scheduler error=%s", type(exc).__name__) + await asyncio.sleep(30) + + +async def start(): + global _worker + if _worker is None and intake_enabled(): + _worker = asyncio.create_task(_run()) + + +async def stop(): + global _worker + if _worker is not None: + _worker.cancel() + with suppress(asyncio.CancelledError): + await _worker + _worker = None diff --git a/app/services/questionnaire.py b/app/services/questionnaire.py new file mode 100644 index 0000000..364a637 --- /dev/null +++ b/app/services/questionnaire.py @@ -0,0 +1,264 @@ +"""Transactional questionnaire storage and a durable doctor notification queue. + +Notifier acceptance is not delivery or a doctor's acknowledgement. At-least-once +retries can duplicate delivery if a process dies after sending but before commit. +""" + +import asyncio +from dataclasses import asdict +from datetime import timedelta +from uuid import uuid4 +from zoneinfo import ZoneInfo + +from sqlalchemy import select + +from app.core import clock +from app.core.intake_questions import content, cssrs_answers, group_answers +from app.core.intake_runtime import load_runtime, month_before +from app.core.questionnaire import evaluate_intake, load_policy, score_alcohol, score_cssrs_recent +from app.core.scales import load_scale, score +from app.core.types import AlertLevel +from app.db import repo +from app.db.clinical import Alert, DialogState +from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask +from app.db.session import get_clinical_session +from app.integrations.base import MemoryNotifier, integrations + + +async def records(session, token): + return list( + ( + await session.scalars( + select(QuestionnaireRecord) + .where(QuestionnaireRecord.token == token) + .order_by(QuestionnaireRecord.started_at, QuestionnaireRecord.id) + ) + ).all() + ) + + +async def create_record(ctx, kind, *, task=None): + profile = await ctx.profile() + runtime = load_runtime() + prior = next( + (r for r in reversed(await records(ctx.session, ctx.token)) if r.kind == "intake" and r.status == "complete"), + None, + ) + sex = profile.sex if profile else None + if sex is None and prior: + sex = prior.details.get("sex") + row = QuestionnaireRecord( + id=uuid4().hex, + token=ctx.token, + kind=kind, + version=content()["version"], + policy_version=load_policy().version, + started_at=ctx.now, + day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(), + status="active", + answers={}, + details={ + "runtime_version": runtime.version, + "modules": list(profile.modules or []) if profile else [], + "sex": sex, + "medication_active": bool( + (profile and profile.therapy_start and profile.therapy_start <= ctx.now.date()) + or (prior and prior.answers.get("psychotropics") == 1) + ), + "suicide_monitoring": bool(profile and profile.enhanced_monitoring), + "weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")), + "answer_metadata": {}, + "task_id": task.id if task else None, + }, + ) + ctx.session.add(row) + await ctx.session.flush() + if task: + task.record_id = row.id + task.status = "active" + return row + + +def save_answer(row, question, value, now): + """Anchor recall windows to administration, never to the day of a resumed answer.""" + day = row.day + starts = { + "past_month": month_before(day), + "past_3_months": month_before(day, 3), + "past_week": day - timedelta(days=7), + "past_day": day - timedelta(days=1), + "последние 2 недели": day - timedelta(days=14), + "последний месяц": month_before(day), + "последняя неделя": day - timedelta(days=7), + "прошлая неделя": day - timedelta(days=7), + "последние 12 месяцев": month_before(day, 12), + } + row.answers = {**row.answers, question.id: value} + if question.id == "sex" and value in ("m", "f"): + row.details = {**row.details, "sex": value} + row.details = { + **row.details, + "answer_metadata": { + **row.details.get("answer_metadata", {}), + question.id: { + "answered_at": now.isoformat(), + "period": question.period, + "period_start": starts[question.period].isoformat() if question.period in starts else None, + "period_end": day.isoformat(), + "source": "patient", + "derived": False, + }, + }, + } + + +def decision(row): + a = row.answers + sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None) + return evaluate_intake( + phq9=group_answers(a, "phq9"), + tsq=group_answers(a, "tsq"), + pcl5=group_answers(a, "pcl5"), + alcohol=group_answers(a, "rus_audit"), + cssrs=cssrs_answers(a), + sex=sex, + trauma_exposure=bool(a["trauma"]) if "trauma" in a else None, + traumatic_brain_injury=a.get("tbi") == 1, + psychotropics=a.get("psychotropics") == 1, + ) + + +def summary(row): + a = row.answers + totals = {} + for sid in ("phq9", "tsq", "pcl5"): + result = score(sid, group_answers(a, sid)) + totals[sid] = { + "complete": result.complete, + "total": result.total if result.complete else None, + "answered": result.answered, + } + sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None) + for sid in ("rus_audit_s", "rus_audit"): + values = {k: a[k] for k in load_scale(sid).item_ids if k in a} + totals[sid] = asdict(score_alcohol(values, short=sid.endswith("_s"), sex=sex)) + totals["cssrs_recent"] = asdict(score_cssrs_recent(cssrs_answers(a))) + return totals + + +async def finish(ctx, row, status, *, extra=None): + row.status = status + row.completed_at = ctx.now if status == "complete" else None + row.details = {**row.details, **(extra or {})} + if row.details.get("task_id"): + task = await ctx.session.get(QuestionnaireTask, row.details["task_id"]) + if task: + task.status = "done" + + +async def alert(session, token, key, code, now, *, level=AlertLevel.MODERATE, params=None): + existing = await session.scalar( + select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.event_key == key) + ) + if existing: + return existing + row = await repo.add_alert(session, token=token, level=level, code=code, reason=code, ts=now, params=params) + notice = QuestionnaireNotice(token=token, event_key=key, alert_id=row.id, created_at=now) + session.add(notice) + await session.flush() + return notice + + +async def cancel_pending(session, token): + for row in await records(session, token): + if row.status in ("active", "paused"): + row.status = "cancelled" + tasks = await session.scalars( + select(QuestionnaireTask).where( + QuestionnaireTask.token == token, QuestionnaireTask.status.in_(("pending", "active")) + ) + ) + for task in tasks: + task.status = "cancelled" + task.delivery = [] + notices = await session.scalars( + select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.status == "pending") + ) + for notice in notices: + notice.status = "cancelled" + + +async def dispatch_notices(token=None): + now = clock.now() + async with get_clinical_session() as session: + stmt = ( + select(QuestionnaireNotice.id, QuestionnaireNotice.token) + .where(QuestionnaireNotice.status == "pending") + .order_by(QuestionnaireNotice.id) + .limit(100) + ) + if token: + stmt = stmt.where(QuestionnaireNotice.token == token) + candidates = (await session.execute(stmt)).all() + for notice_id, patient_token in candidates: + async with get_clinical_session() as session: + # Same lock order as withdrawal: patient first, then notice. + await lock_patient(session, patient_token) + notice = await session.scalar( + select(QuestionnaireNotice).where(QuestionnaireNotice.id == notice_id).with_for_update() + ) + if notice.status != "pending": + continue + if notice.attempted_at and now < notice.attempted_at + timedelta( + minutes=min(60, 2 ** min(notice.attempts, 6)) + ): + continue + state = await repo.get_dialog_state(session, notice.token) + if state is None or state.data.get("consent") is not True: + notice.status = "cancelled" + continue + profile = await repo.get_profile(session, notice.token) + if not profile or not profile.doctor_id: + continue + item = await session.get(Alert, notice.alert_id) + notice.attempted_at, notice.attempts = now, notice.attempts + 1 + try: + await asyncio.wait_for( + integrations.notifier.send(profile.doctor_id, item.level, item.code, token=notice.token), timeout=10 + ) + except Exception as exc: + notice.error_code = type(exc).__name__[:80] + else: + notice.error_code = None + notice.accepted_at = now + notice.status = ( + "test_sink" if isinstance(integrations.notifier, MemoryNotifier) else "accepted_by_adapter" + ) + + +async def queue_task(session, token, kind, key, now): + existing = await session.scalar( + select(QuestionnaireTask).where(QuestionnaireTask.token == token, QuestionnaireTask.task_key == key) + ) + if existing: + return existing + # Only one outstanding administration per form, however many ticks were missed. + existing = await session.scalar( + select(QuestionnaireTask).where( + QuestionnaireTask.token == token, + QuestionnaireTask.kind == kind, + QuestionnaireTask.status.in_(("pending", "active")), + ) + ) + if existing: + return existing + row = QuestionnaireTask(token=token, kind=kind, task_key=key, due_at=now) + session.add(row) + await session.flush() + return row + + +async def lock_patient(session, token): + # PostgreSQL serializes callbacks from different workers. SQLite tests additionally + # use the engine's in-process lock. Consent and answers share this transaction. + await session.scalar(select(DialogState).where(DialogState.token == token).with_for_update()) diff --git a/config/instruments/diary.yaml b/config/instruments/diary.yaml new file mode 100644 index 0000000..5c0d73c --- /dev/null +++ b/config/instruments/diary.yaml @@ -0,0 +1,189 @@ +version: diary-v2-2026-10-03 +core: +- id: mood + 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: sleep_hours + text: Сколько часов Вы спали прошлой ночью? + numeric: true +- id: rested + text: 'Насколько Вы выспались? + + 0 — совсем не выспались, 4 — полностью выспались.' + options: + - value: 0 + label: '0' + - value: 1 + label: '1' + - value: 2 + label: '2' + - value: 3 + label: '3' + - value: 4 + label: '4' +- id: medication + text: Приняли ли Вы сегодня лекарства по схеме? + options: + - value: full + label: По схеме + - value: partial + label: Частично + - value: none + label: Нет + - value: not_prescribed + label: Не назначены +- id: medication_reason + text: Почему не приняли лекарства по схеме? + options: + - value: forgot + label: Забыл(а) + - value: side_effects + label: Побочные эффекты + - value: declined + label: Не хочу + - value: ran_out + label: Закончились + - value: other + label: Другая причина +- id: side_effects + text: Беспокоили ли Вас сегодня побочные эффекты лекарств? + options: &id001 + - value: 1 + label: Да + - value: 0 + label: Нет +- id: substances + text: Употребляли ли Вы сегодня алкоголь или другие вещества? + options: *id001 +- id: comment + text: Что хотите передать врачу? + optional: true +ptsd: +- id: intrusions + text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?' + options: &id002 + - value: 0 + label: Совсем нет + - value: 1 + label: Немного + - value: 2 + label: Умеренно + - value: 3 + label: Сильно + - value: 4 + label: Крайне сильно +- id: nightmares + text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?' + options: *id002 +- id: avoidance + text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?' + options: *id002 +- id: hypervigilance + text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?' + options: *id002 +- id: irritability + text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?' + options: *id002 +depression: +- id: interest + text: 'Насколько Вам сегодня было интересно и приятно что-то делать? + + 0 — совсем нет, 4 — крайне сильно.' + options: *id002 +- id: depressed + text: Насколько Вы сегодня чувствовали подавленность или безнадёжность? + options: *id002 +- id: energy + text: 'Насколько у Вас сегодня были силы и энергия? + + 0 — совсем нет, 4 — крайне сильно.' + options: *id002 +- id: planned_activity + text: Сделали ли Вы сегодня хотя бы одно запланированное дело? + options: *id001 +suicide: +- id: death_thoughts + text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить? + options: *id001 +- id: thoughts_intensity + text: 'Если да — насколько сильными они были? + + 0 — совсем слабые, 10 — максимально сильные.' + options: + - value: 0 + label: '0' + - value: 1 + label: '1' + - value: 2 + label: '2' + - value: 3 + label: '3' + - value: 4 + label: '4' + - value: 5 + label: '5' + - value: 6 + label: '6' + - value: 7 + label: '7' + - value: 8 + label: '8' + - value: 9 + label: '9' + - value: 10 + label: '10' +- id: hope + text: 'Насколько Вы верите, что станет лучше? + + 0 — совсем не верю, 10 — полностью верю.' + options: + - value: 0 + label: '0' + - value: 1 + label: '1' + - value: 2 + label: '2' + - value: 3 + label: '3' + - value: 4 + label: '4' + - value: 5 + label: '5' + - value: 6 + label: '6' + - value: 7 + label: '7' + - value: 8 + label: '8' + - value: 9 + label: '9' + - value: 10 + label: '10' +- id: burden + text: Чувствовали ли Вы себя обузой для близких? + options: *id001 + weekly: true diff --git a/config/instruments/intake_forms.yaml b/config/instruments/intake_forms.yaml new file mode 100644 index 0000000..0321bf5 --- /dev/null +++ b/config/instruments/intake_forms.yaml @@ -0,0 +1,167 @@ +version: mvp-v2-2026-10-03 +forms: + phq9: + id: phq9 + title: Опросник здоровья пациента (PHQ-9) + recall_period: последние 2 недели + instruction: Как часто за последние 2 недели Вас беспокоили следующие проблемы? + license_note: PHQ-9 (Pfizer) — свободное использование, разрешения не требуется. + translation_note: Стандартная русская версия PHQ-9 (MAPI). Финальную формулировку утверждает психиатр. + options: + - value: 0 + label: Ни разу + - value: 1 + label: Несколько дней + - value: 2 + label: Более недели + - value: 3 + label: Почти каждый день + items: + - id: phq9_1 + text: Вам не хотелось ничего делать + - id: phq9_2 + text: У Вас было плохое настроение, Вы были подавлены или испытывали чувство безысходности + - id: phq9_3 + text: Вам было трудно заснуть, у Вас был прерывистый сон, или Вы слишком много спали + - id: phq9_4 + text: Вы были утомлены, или у Вас было мало сил + - id: phq9_5 + text: У Вас был плохой аппетит, или Вы переедали + - id: phq9_6 + text: 'Вы плохо о себе думали: считали себя неудачником (неудачницей), или были в себе разочарованы, или считали, + что подвели свою семью' + - id: phq9_7 + text: Вам было трудно сосредоточиться (например, на чтении газеты или на просмотре телепередач) + - id: phq9_8 + text: Вы двигались или говорили настолько медленно, что окружающие это замечали? Или, наоборот, Вы были настолько + суетливы или взбудоражены, что двигались гораздо больше обычного + - id: phq9_9 + text: Вас посещали мысли о том, что Вам лучше было бы умереть, или о том, чтобы причинить себе какой-нибудь + вред + buttons_only: true + range: + min: 0 + max: 27 + severity: + - min: 0 + max: 4 + code: minimal + label: Минимальная + - min: 5 + max: 9 + code: mild + label: Лёгкая + - min: 10 + max: 14 + code: moderate + label: Умеренная + - min: 15 + max: 19 + code: moderately_severe + label: Умеренно-тяжёлая + - min: 20 + max: 27 + code: severe + label: Тяжёлая + flags: + - code: phq9_item9_positive + when: + item: phq9_9 + op: '>=' + value: 1 + pcl5: + id: pcl5 + title: Контрольный список симптомов ПТСР (PCL-5) + recall_period: последний месяц + instruction: 'Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового + события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, + который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. + + Насколько сильно за последний месяц Вас беспокоили:' + license_note: PCL-5 — общественное достояние (VA National Center for PTSD); русская версия — лицензия на проверке. + translation_note: Рабочий перевод команды (демо-текст). Финальный русский перевод выбирает психиатр (SPEC §9). + Для еженедельного мониторинга используется та же шкала с периодом «последняя неделя». + options: + - value: 0 + label: Совсем нет + - value: 1 + label: Немного + - value: 2 + label: Умеренно + - value: 3 + label: Сильно + - value: 4 + label: Крайне сильно + items: + - id: pcl5_1 + text: Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? + - id: pcl5_2 + text: Повторяющиеся тяжёлые сны о стрессовом событии? + - id: pcl5_3 + text: Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно + вернулись туда и переживаете его заново)? + - id: pcl5_4 + text: Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? + - id: pcl5_5 + text: Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, + затруднённое дыхание, потливость)? + - id: pcl5_6 + text: Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? + - id: pcl5_7 + text: Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов + или ситуаций)? + - id: pcl5_8 + text: Трудности с тем, чтобы вспомнить важные части стрессового события? + - id: pcl5_9 + text: Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то + серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? + - id: pcl5_10 + text: Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? + - id: pcl5_11 + text: Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? + - id: pcl5_12 + text: Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? + - id: pcl5_13 + text: Ощущение отдалённости или оторванности от других людей? + - id: pcl5_14 + text: Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость + или любовь к близким людям)? + - id: pcl5_15 + text: Раздражительность, вспышки гнева или агрессивное поведение? + - id: pcl5_16 + text: Чрезмерный риск или поступки, которые могут причинить Вам вред? + - id: pcl5_17 + text: Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? + - id: pcl5_18 + text: Взвинченность или склонность легко вздрагивать? + - id: pcl5_19 + text: Трудности с концентрацией внимания? + - id: pcl5_20 + text: Трудности с засыпанием или прерывистый сон? + range: + min: 0 + max: 80 + severity: + - min: 0 + max: 32 + code: below_threshold + label: Ниже порога ПТСР + - min: 33 + max: 49 + code: probable + label: Вероятное ПТСР + - min: 50 + max: 80 + code: high + label: Выраженная симптоматика ПТСР + flags: + - code: pcl5_probable_ptsd + when: + total: true + op: '>=' + value: 33 +trauma_introduction: 'Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или + травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение + или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства + или самоубийства.' +trauma_filter: Случалось ли с Вами подобное событие? diff --git a/config/intake_runtime.yaml b/config/intake_runtime.yaml new file mode 100644 index 0000000..0d187f4 --- /dev/null +++ b/config/intake_runtime.yaml @@ -0,0 +1,26 @@ +version: intake-v2-2026-10-03 +enabled: false +approval: null +# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness. +content_review: null +contact_review: null +timezone: Europe/Moscow +daily_hour: 19 +mood_baseline_days: 7 +mood_bad_days: 3 +mood_drop: 3 +sleep_bad_days: 3 +sleep_hours_below: 4 +nightmares_at: 3 +missed_medication_days: 3 +high_risk_silence_days: 2 +medication_days: [7, 14, 28] +medication_repeat_days: 28 +route_intervals: + after_crisis: {phq9: 7, pcl5: 14} + specialist: {phq9: 14, pcl5: 30} + self_help: {phq9: 28, pcl5: 28} +alcohol_repeat_days: 30 + +phq9_retriage_delta: 5 +pcl5_retriage_delta: 10 diff --git a/config/questionnaire.yaml b/config/questionnaire.yaml index 263f080..4f5937f 100644 --- a/config/questionnaire.yaml +++ b/config/questionnaire.yaml @@ -1,10 +1,10 @@ # Review candidate from the supplied MVP PDF, not a signed clinical protocol. -version: mvp-review-2026-10-03 +version: mvp-v2-review-2026-10-03 approval: null cssrs_positive_policy: pending rus_audit_s_expand_from_zone: null phq2_expand_at: 2 -pcptsd5_expand_at: 3 +tsq_expand_at: 6 phq9_psychologist_at: 5 phq9_specialist_at: 10 phq9_priority_at: 20 diff --git a/config/scales/tsq.yaml b/config/scales/tsq.yaml new file mode 100644 index 0000000..34cd9db --- /dev/null +++ b/config/scales/tsq.yaml @@ -0,0 +1,59 @@ +id: tsq +title: Опросник на скрининг ПТСР (TSQ) +recall_period: прошлая неделя +instruction: Эта анкета связана с вашими личными реакциями на травматическое событие, которое случилось с вами. + Ниже указаны некоторые реакции, которые иногда возникают у людей после травматического события. Пожалуйста, ответьте + «Да», если вы испытывали следующие симптомы по крайней мере дважды на прошлой неделе. +translation_note: КР ПТСР 2023, ID 753, приложение Г1, с.129–130 PDF; копия emdr.ru. Опечатки сохранены. Актуальность + редакции не подтверждена. +options: +- value: 1 + label: ДА +- value: 0 + label: НЕТ +items: +- id: tsq_1 + text: Тяжелые мысли или воспоминания о событии приходили мне в голову против моей воли + buttons_only: true +- id: tsq_2 + text: Мне снились тяжелые сны о том, что со мной случилось + buttons_only: true +- id: tsq_3 + text: Я вдруг замечал(а), что действую и чувствую себя так, как будто бы ситуация повторяется снова + buttons_only: true +- id: tsq_4 + text: Когда что-то напоминает мне об этом событии, я чувствую себя подавленным + buttons_only: true +- id: tsq_5 + text: Когда что-то напоминало мне о случившемся, я испытывал(а) неприятные физические ощущения (потливость, сбой + дыхания, тошноту, учащение пульса и др.) + buttons_only: true +- id: tsq_6 + text: У меня нарушен сон (трудности засыпания или частые пробуждения) + buttons_only: true +- id: tsq_7 + text: Я чувствовал(а) постоянное раздражение и гнев + buttons_only: true +- id: tsq_8 + text: Мне было сложно сосредоточится + buttons_only: true +- id: tsq_9 + text: Я стал более осведомлён о потенциальных опасностях для себя и других + buttons_only: true +- id: tsq_10 + text: Я все время был(а) напряжен(а) и вздрагивал(а), если что-то внезапно пугало меня + buttons_only: true +range: + min: 0 + max: 10 +severity: +- min: 0 + max: 5 + code: negative + label: Ниже порога +- min: 6 + max: 10 + code: positive + label: Требуется уточнение + +license_note: "Текст приложения Г1 клинических рекомендаций 2023 года; условия использования и актуальную редакцию подтвердить до включения." diff --git a/config/texts/crisis.yaml b/config/texts/crisis.yaml index e8865db..eccbd41 100644 --- a/config/texts/crisis.yaml +++ b/config/texts/crisis.yaml @@ -14,9 +14,9 @@ resources: phone: "112" note: "если есть угроза жизни прямо сейчас" helpline: - name: "Телефон доверия" - phone: "8-800-2000-122" - note: "бесплатно, круглосуточно" + name: "Психологическая помощь МЧС России" + phone: "+7 (495) 989-50-50" + note: "круглосуточно; стоимость звонка по тарифу оператора" police: name: "Полиция" phone: "102" @@ -30,7 +30,7 @@ variants: себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку и побудьте рядом с кем-то, кому доверяете. resources: [emergency, helpline] - doctor_notified: "Ваш врач уже уведомлён и свяжется с вами." + doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112." grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом." patient.harm_others: @@ -39,7 +39,7 @@ variants: Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее. Если кому-то угрожает опасность прямо сейчас — позвоните 112. resources: [emergency, helpline] - doctor_notified: "Ваш врач уже уведомлён и свяжется с вами." + doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112." grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз." patient.domestic_violence: @@ -48,7 +48,7 @@ variants: Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112. Кризисные службы и телефон доверия помогут найти безопасное место и поддержку. resources: [emergency, police, helpline] - doctor_notified: "Ваш врач уже уведомлён." + doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112." grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом." family.default: @@ -58,7 +58,7 @@ variants: спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите — просто будьте рядом и слушайте. resources: [emergency, helpline] - doctor_notified: "Лечащий врач уже уведомлён." + doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112." grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6." family.domestic_violence: @@ -68,5 +68,5 @@ variants: Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112. Кризисные службы помогут с временным убежищем и поддержкой. resources: [emergency, police, helpline] - doctor_notified: "Лечащий врач уже уведомлён." + doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112." grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6." diff --git a/config/texts/dialog.yaml b/config/texts/dialog.yaml index f295afd..c2b58cb 100644 --- a/config/texts/dialog.yaml +++ b/config/texts/dialog.yaml @@ -75,9 +75,9 @@ revoked: crisis: # Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы. - repeat: "Я рядом. Ваш врач уже уведомлён. Если вам угрожает опасность прямо сейчас — позвоните:" + repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:" # Любое другое сообщение, пока кризис открыт (до подтверждения врача). - hold: "Я на связи и читаю вас. Ваш врач в курсе и свяжется с вами. Если станет хуже — звоните 112 или на телефон доверия 8-800-2000-122." + hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50." # Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса. closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент." @@ -97,7 +97,7 @@ safety_plan: - "Что помогает мне самому: прогулка, душ, дыхание, любимое дело." - "Люди и места, которые отвлекают: к кому можно пойти." - "Кому позвонить, если тяжело: близкий человек, сослуживец." - - "Специалисты: ваш врач, телефон доверия 8-800-2000-122, экстренные службы 112." + - "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112." - "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку." means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя." @@ -111,4 +111,4 @@ visit: unavailable: |- Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже. - Если вам плохо прямо сейчас — звоните 112 или на телефон доверия 8-800-2000-122. + Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50. diff --git a/config/texts/intake.yaml b/config/texts/intake.yaml new file mode 100644 index 0000000..0e88489 --- /dev/null +++ b/config/texts/intake.yaml @@ -0,0 +1,33 @@ +unavailable: "Этот опрос пока недоступен. Если нужна помощь, свяжитесь с врачом." +done: "Ответы сохранены. Дальнейшие действия можно обсудить с врачом." +review: "Опрос приостановлен. Для продолжения нужна оценка специалиста. Если есть непосредственная угроза жизни — позвоните 112." +paused: "Опрос приостановлен, ответы сохранены." +invalid: "Выберите один из предложенных вариантов ответа." +numeric: "Введите число часов от 0 до 24, например 6,5." +help: "Мне нужна помощь сейчас" +pause: "Продолжить позже" +resume: "Продолжить опрос" +start: "Пройти первичный опрос" +comment_skip: "Не добавлять комментарий" +recent_trauma: "Событие произошло менее месяца назад?" +sex: "Укажите пол для интерпретации алкогольной шкалы." +tbi: "Были ли у Вас контузия или черепно-мозговая травма?" +psychotropics: "Принимаете ли Вы сейчас психотропные препараты?" +missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже." +periods: + past_month: "За последний месяц." + lifetime: "За всю жизнь." + past_3_months: "За последние три месяца." +standard_drinks: |- + Стандартная порция (СП) = 10 г этанола. Таблица бланка RUS-AUDIT: + Вино/шампанское · пиво · креплёное вино · крепкий алкоголь + 1–2 СП: до 250 мл · до 650 мл · до 170 мл · до 80 мл + 3–4 СП: 251–450 мл · 651–1200 мл · 171–300 мл · 81–140 мл + 5–6 СП: 451–660 мл · 1201–1750 мл · 301–430 мл · 141–210 мл + 7–9 СП: 661–970 мл · 1751–2500 мл · 431–640 мл · 211–300 мл + 10+ СП: более 970 мл · более 2500 мл · более 640 мл · более 300 мл + Если напитков несколько, учитывайте суммарное число стандартных порций. + +as_of: "Период отсчитывается от даты начала этого опроса: {day}." + +alcohol_advice: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план." diff --git a/docs/clinical/IMPLEMENTATION.md b/docs/clinical/IMPLEMENTATION.md new file mode 100644 index 0000000..966b7fa --- /dev/null +++ b/docs/clinical/IMPLEMENTATION.md @@ -0,0 +1,117 @@ +# Реализация обновлённого опросника — PR #53 + +Спецификация: 19-страничный PDF, SHA-256 +`b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e`. +Состояние: техническая реализация для проверки; клиническое включение закрыто. + +## Состав + +- Первичный диалог: официальный русский C-SSRS Recent с условными переходами, + PHQ-2 → PHQ-9 при сумме ≥2, фильтр травмы → TSQ → PCL-5 при TSQ ≥6, + RUS-AUDIT-S 3/9/10 → полная форма по отдельно утверждаемому условию. + Вопросы о недавности травмы, ЧМТ и психотропных препаратах сохраняются отдельно. +- TSQ: дословная инструкция и десять пунктов приложения Г1, с.129–130 PDF + клинических рекомендаций 2023 года. Копия, источник и хеш — + `tsq-guideline-2023.pdf`, `tsq-source.json`. Актуальность редакции не подтверждена. +- Полная алкогольная форма сохраняет ответы 3/9/10 без повторного суммирования; + п.1=0 выводит только п.2=0. Конфликт с положительным п.3 передаётся на проверку. + Отказ указать пол не подменяется выбором мужских или женских границ. +- PHQ/PCL взяты из новой версии PDF; обратный перевод и таблица согласования + находятся в `revision-2026-10-03-v2/`. Тексты не считаются согласованными. +- Дневник: настроение, часы сна, восстановление после сна, приём лекарств и причина + пропуска, переносимость, вещества, необязательный комментарий; модули ПТСР, + депрессии, суицидальных мыслей. У группы с риском вопрос о мыслях задаётся первым. + Общая сумма дневника не вычисляется. +- Повторы: 7/14/28 дней от начала/смены терапии, затем 28; после кризиса PHQ-9 — 7, + PCL-5 — 14 с недельным периодом; специалист — 14/30; самопомощь — 28/28; + RUS-AUDIT-S — 30. При пересечении выбирается ближайшая дата. ЧМТ и алкогольные + направления сохраняются параллельно основному. +- Повторные PHQ/PCL сохраняют маршрут и изменение относительно последнего полного + результата той же версии и периода. Рост ≥5/≥10 помечается для повторного триажа. + Недельный PCL-5 не сравнивается с месячным. Переносимость уточняется при повторе + на фоне зафиксированной терапии. + +## Остановка и работа врача + +Положительный PHQ-9/9, высокий сигнал C-SSRS, мысли о смерти в дневнике и кнопка +помощи немедленно останавливают обычные вопросы. Сохраняются частичные ответы, +причина, дополнительные флаги и обращение. Кризисный ответ содержит 112 и кнопку +плана безопасности. Средний риск C-SSRS требует оценки специалиста; низкий при +утверждённой политике `graded` показывает план безопасности и обращение врачу. +Альтернатива `stop_any` прекращает опрос при первом «Да». Выбор остаётся за врачом. + +**Русский Since Last Contact не получен.** При повторном мониторинге создаётся +отдельное задание для оценки специалистом; Recent не переименовывается и не +выдаётся за повторную форму. Перед дневником/плановым мониторингом предусмотрено +одно такое задание на местный календарный день. Обычный сценарий ждёт решения +врача. Это явно обозначенный временный порядок, который также требует согласования. +Дневниковое «Да» не ждёт окончания дневника или этой плановой процедуры. + +Новые служебные маршруты: + +- `GET /api/v1/patients/{token}/questionnaires?doctor_id=...` — ответы, версия, + периоды и результаты; чтение записывается в аудит. +- `POST /api/v1/patients/{token}/questionnaires/{record_id}/review` — поля + `doctor_id`, `assessment_performed: true`, `decision: resume | keep_hold`, + `reference` (идентификатор решения, без ФИО/свободного медицинского текста). + Проверяются принадлежность пациента врачу, согласие и статус записи. + Решение сохраняется с датой и аудитом. При открытом кризисе продолжение запрещено. + Простое подтверждение получения уведомления не снимает этот запрет проверки. + +Как и остальной API этой ветки, эти маршруты предназначены для синтетического +прототипа: `doctor_id` передаёт вызывающая сторона. Перед реальными данными нужна +аутентификация врача; существующая карточка дашборда остаётся отдельной заглушкой. + +## Надёжность и данные + +Три новые таблицы: `questionnaire_records`, `questionnaire_notices`, +`questionnaire_tasks`. Они создаются существующим `init_db`; прежние результаты +шкал не переименовываются и не мигрируют. Ответ, переход и обращение фиксируются +в одной транзакции. Неполные суммы — `null`, C-SSRS не получает суммарного балла. +Выведенный п.2 RUS-AUDIT отмечается в `derived`, отсутствуя среди исходных ответов. +Период привязан к началу конкретного заполнения; у каждого ответа есть время. + +Повторное нажатие и кнопка от другого вопроса не меняют следующий ответ. +Приостановленный опрос восстанавливается по сохранённой записи; смена версии +переводит его на проверку. После кризиса автоматического продолжения нет. +Отзыв согласия отменяет незавершённые записи, задания и неотправленные обращения. + +Обращения врачу повторяются после ошибки с ограниченной задержкой; в ошибках +сохраняется только класс исключения. `accepted_by_adapter` означает приём адаптером, +`test_sink` — сохранение демонстрационным адаптером. Доставка человеку не утверждается. +Исходящие плановые сообщения сохраняются в задании до попытки отправки. Протокол +канала не поддерживает ключ идемпотентности: после падения между отправкой и +фиксацией возможна повторная доставка; потеря очереди внутри MemoryOutbox после +перезапуска также не является подтверждённой доставкой. Для реального запуска +нужен постоянный адаптер с подтверждением доставки. + +Планировщик использует часы приложения и Europe/Moscow. PostgreSQL блокирует +параллельные запуски планировщика advisory lock, ответы пациента — блокировкой +строки. В SQLite демонстрации используется один процесс. Пропущенные тики не +создают пачку исторических анкет. Перезапуск не создаёт новое открытое задание +того же типа. Изменение схемы лекарств должно иметь новую дату начала в источнике +назначений; изменение дозировки без даты не угадывается. + +## Предложенные определения для согласования + +В `config/intake_runtime.yaml`: семь полных календарных дней ДО трёх дней ухудшения +образуют базовый уровень; пропуски не заменяются нулями. Для каждой из трёх ночей +проверяется «сон <4 часов ИЛИ кошмары ≥3». Только явное «не принимал» три дня подряд +считается пропуском; частичный приём и побочные эффекты — отдельные флаги. +Два дня без завершённого дневника в группе риска создают обращение для контакта; +сам звонок этим кодом не выполняется. При двух модулях подробные вопросы ПТСР и депрессии чередуются по календарным +дням; вопросы о риске остаются ежедневно. Обещание заполнить за одну минуту не используется. Якоря положительных показателей +и разбиение вопроса о сне — кандидатные формулировки для врача. + +## Включение и остающиеся внешние действия + +По умолчанию `enabled: false`; все подписи пусты. Для включения нужны решение по +C-SSRS, порог расширения алкоголя (сумма ИЛИ зона), подпись политики и три подписи +той же версии runtime: маршрутизация/дневники/расписания, содержание, контакты. +Тестовые подписи создаются только во временных каталогах тестов. После изменения +конфигурации требуется перезапуск приложения. + +До работы с пациентами остаются: врачебное согласование, независимая проверка +перевода, проверка актуальной редакции КР и условий использования TSQ, получение +русского Since Last Contact, операционная проверка линий и готовности дежурного. +Публикация PR не выполняет ни один из этих пунктов. diff --git a/docs/clinical/README.md b/docs/clinical/README.md index b4f9ba2..ea32dce 100644 --- a/docs/clinical/README.md +++ b/docs/clinical/README.md @@ -10,8 +10,10 @@ Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md) и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания. -**Код пока следует предыдущей версии, новая спецификация ещё не реализована.** -Текст TSQ в новом PDF отсутствует: перечислены только темы. +**Новая спецификация подключена к диалогу за закрытым переключателем.** +[Состав реализации, ограничения и порядок проверки](IMPLEMENTATION.md). +Текст TSQ получен отдельно из приложения Г1 клинических рекомендаций 2023 года; +актуальность этой редакции ещё нужно подтвердить. Независимая проверка перевода остаётся pending. Описания реализованных правил и ссылки на страницы ниже относятся к исходному 14-страничному PDF, если явно не указана новая версия. @@ -21,9 +23,9 @@ `app/core/questionnaire.py` содержит детерминированные правила первичного опроса: короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5, условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent, -приоритет основного маршрута и параллельные направления. Это новый модуль для -интеграции после проверки, а не уже подключённый сценарий бота. Существующие -контракты, `config/modules.yaml`, мониторинг и исторические результаты не менялись. +приоритет основного маршрута и параллельные направления. Правила подключены к версионированному диалогу, дневнику и планировщику. +Замороженные контракты, `config/modules.yaml` и исторические результаты не менялись. +Клиническое включение заблокировано до подписей. `config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий @@ -58,7 +60,7 @@ DOI 10.17116/profmed20222508121. Рисунок визуально провер При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10 переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт получения ответа в краткой форме: её пояснение травмы отличается от полного текста. -Правило PDF `п.1 = 0` может вывести нули только для 2/3, **не для 9/10**; +Правило PDF `п.1 = 0` в новой версии выводит ноль только для 2; исходный 3 сохраняется, как и 9/10; выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется. Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является @@ -152,9 +154,9 @@ q6 за всю жизнь всем; при положительном q6 уто | ID | Решение, которое нужно зафиксировать | Статус | |---|---|---| | C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending | -| C02 | С какой зоны RUS-AUDIT-S дополнять полную форму? Не переносить порог AUDIT-C ≥3/≥4. | pending | +| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending | | C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending | -| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. Код пока использует PC-PTSD-5. | pending | +| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending | | C05 | Привязка PHQ-9/PCL-5 к специалистам, сроки срочной помощи, параллельные маршруты и состав флагов. | pending | | C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending | | C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending | @@ -170,7 +172,8 @@ q6 за всю жизнь всем; при положительном q6 уто ## Подключение к диалогу -Адаптер вызывает `evaluate_intake` после каждого ответа и на ручной кнопке помощи. +Адаптер проверяет непосредственные сигналы после каждого ответа и на кнопке помощи; +при завершении вызывает `evaluate_intake` для общего маршрута. `crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required` не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные пропуски; `ready` содержит основной маршрут и отдельные параллельные направления. diff --git a/docs/clinical/crisis-contacts.md b/docs/clinical/crisis-contacts.md index 6da9c82..4a9309a 100644 --- a/docs/clinical/crisis-contacts.md +++ b/docs/clinical/crisis-contacts.md @@ -10,11 +10,20 @@ | Московская служба психологической помощи | 051 с городского; 8 (495) 051 с мобильного | Москва, региональный ресурс | [Страница службы](https://msph.ru/uslugi/neotlozhnaya-psikhologicheskaya-pomoshch): круглосуточно; городской звонок бесплатный, мобильный тарифицируется оператором. Не преобразовывать необычный короткий номер в выдуманный E.164. | | Детский телефон доверия | 8-800-2000-122 | Дети, подростки и родители по вопросам ребёнка | [Официальная страница](https://telefon-doveria.ru/about/): аудитория, анонимность и бесплатность. Не универсальная взрослая кризисная линия. Часы работы конкретного регионального подразделения нужно уточнить. | -В существующем `config/texts/crisis.yaml` номер 8-800-2000-122 ошибочно подписан -просто «Телефон доверия» для всех пациентов. Взрослой ветке нужен ресурс для взрослых; -детский оставить с точной аудиторией. Замена номера и текстов в активном диалоге -должна быть согласована с владельцем кризисного сценария и врачом (#8). -Этот пакет проверок сам по себе активный кризисный экран не меняет. +В `config/texts/crisis.yaml` универсальная взрослая ветка исправлена на линию МЧС; +детский номер сохранён в возрастной ветке с указанием аудитории. Безусловное +обещание уведомления врача удалено. Формулировки и готовность линий требуют +согласования перед включением нового опросника. + +## Дополнения из новой версии PDF + +| Ресурс | Проверка источника | Ограничение | +|---|---|---| +| МГППУ, 8-800-600-31-14 | [Сообщение МГППУ](https://mgppu.ru/news/10402) публикует номер для детей и родителей | Не включён как универсальная линия для всех взрослых; часы и доступность проверить у службы. | +| Санкт-Петербург, 8 (812) 708-40-41 | [Справочник администрации](https://www.gov.spb.ru/gov/admgub/obrasheniya/faq/13551/) указывает телефон Института «Гармония» для детей и взрослых | Региональный ресурс; охват Ленинградской области отдельно не подтверждён. Повторное открытие страницы дало ошибку; перед включением перепроверить. | +| Фонд «Защитники Отечества», 117, доб.1 | [Страница государственного медучреждения](https://xn--7--9kcgbq1fn7g.xn--p1ai/информация-для-участников-специальн/) указывает номер фонда | Сайт самого фонда при проверке недоступен. Номер поддержки ветеранов и семей не объявляется круглосуточной экстренной психиатрической линией. В активный кризисный экран не добавлен. | + +Звонки по этим трём позициям также не выполнены. Номер 112 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих diff --git a/docs/clinical/review-status.json b/docs/clinical/review-status.json index f813df3..b1ba629 100644 --- a/docs/clinical/review-status.json +++ b/docs/clinical/review-status.json @@ -26,21 +26,18 @@ "note": "User supplied the file after the official-source download workflow was blocked. Local content and colors checked; publisher version is not inferred from PDF metadata." }, "intake_release_ready": false, - "policy_version": "mvp-review-2026-10-03", + "policy_version": "mvp-v2-review-2026-10-03", "latest_specification": { "source_pdf": "revision-2026-10-03-v2/questionnaires-mvp-v2.pdf", "sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e", "pages": 19, "review": "revision-2026-10-03-v2/REVIEW.md", - "implemented": false, - "pending_changes": [ - "tsq_replaces_pcptsd5", - "alcohol_q1_zero_derives_q2_only", - "daily_diary", - "medication_followup_schedule" - ], - "tsq_verbatim_source_verified": false, - "current_guideline_edition_verified": false + "implemented": true, + "pending_changes": [], + "tsq_verbatim_source_verified": true, + "current_guideline_edition_verified": false, + "implementation_scope": "gated_intake_diaries_schedules_manual_followup_risk_review", + "implementation": "IMPLEMENTATION.md" }, "backtranslation": { "units": 32, @@ -48,5 +45,9 @@ "reconciliation": "revision-2026-10-03-v2/translation-reconciliation.tsv", "clinician_approval": null, "independent_review": "not_performed" + }, + "cssrs_since_contact": { + "russian_source_received": false, + "fallback": "clinician_assessment_required_before_routine_monitoring" } } diff --git a/docs/clinical/revision-2026-10-03-v2/REVIEW.md b/docs/clinical/revision-2026-10-03-v2/REVIEW.md index b44b197..d89d131 100644 --- a/docs/clinical/revision-2026-10-03-v2/REVIEW.md +++ b/docs/clinical/revision-2026-10-03-v2/REVIEW.md @@ -5,22 +5,25 @@ в `source-manifest.json`. Все страницы новой версии прочитаны и просмотрены. Это разбор спецификации и языкового материала, не клиническое согласование. +Технический этап выполнен после разбора: [реализация и ограничения](../IMPLEMENTATION.md). +Ниже сохранены замечания к источнику с обновлённым статусом реализации. + ## Что изменилось и как это влияет на реализацию | Раздел новой версии | Изменение | Состояние реализации / дальнейшее действие | |---|---|---| -| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Текущий `evaluate_intake` ещё использует PC-PTSD-5 и порог 3. Нужны новая версия шкалы и миграция правил; переименования недостаточно. | +| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Реализован TSQ из приложения Г1 редакции 2023 года: 10 пунктов, порог 6; прежние результаты не переименованы. | | с.3 | Вступление и фильтр травмы PC-PTSD-5 сохранены; при «Нет» TSQ не задаётся | Записывать «не проводился по фильтру», не десять нулевых ответов. Вопрос о травме не является одиннадцатым пунктом TSQ. | -| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Требуется дословная инструкция и пункты из приложения Г1 с проверенной редакцией. | +| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Дословный текст получен из приложения Г1 редакции 2023 года; актуальность редакции ещё не подтверждена. | | с.5–7 | Обосновано сохранение PCL-5 вместо IES-R; предложено сравнение на пилоте | 20 русских пунктов, инструкции и варианты PCL-5 не изменились; поменялось расположение на страницах. Пилот и валидация не выполнены. | | с.8–10 | RUS-AUDIT-S исправлен на 3/9/10; повтор тоже краткой формой | Совпадает с уже реализованным составом. Временное расширение при любом ненулевом ответе не равно переходу с зоны II. Решение C02 остаётся открытым. | -| с.8 | При п.1=0 теперь явно пропускается только п.2 | Текущий модуль выводит нули для п.2 и п.3 по старому источнику. В новом потоке п.3 уже задан на первом этапе: сохранить исходный ответ, противоречие уточнить. Изменение кода ещё не внесено. | +| с.8 | При п.1=0 теперь явно пропускается только п.2 | Исправлено: выводится только п.2=0; п.3 сохраняется. Конфликт останавливает поток для проверки. | | с.10 | В PDF по-прежнему не вписаны зоны краткой формы | В PR они уже есть по рис.3 Буновой: женщины 0–1 / 2–4 / 5 / ≥6; мужчины 0–3 / 4–6 / 7 / ≥8. Это не автоматическое утверждение порога расширения. | | с.11–12 | Добавлена ссылка на русские бланки Columbia | Recent уже получен и сверён в PR. Для мониторинга требуется отдельный официальный Since Last Contact; текущий Recent его не заменяет. | -| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Нужны дата начала/смены, правила объединения расписаний и вопрос о переносимости. Сам вопрос о побочных эффектах пока не сформулирован. | -| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. В текущий код не добавлены. | +| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Расписания и объединение реализованы, вопрос о переносимости добавлен как кандидатный. Требуются согласование и корректные даты изменений назначений. | +| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. Добавлены за закрытым переключателем; определения и якоря ждут согласования. | | с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. | -| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Предыдущая проверка официальных сайтов охватывала 112, МЧС, московскую и детскую линии; новые три позиции отдельно не подтверждены. | +| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Проверка источников дополнена в ../crisis-contacts.md; проверка соединения не выполнена. | ## Противоречия и недостающие определения @@ -88,12 +91,10 @@ PC-PTSD-5 и его отдельная инструкция о месяце. ## Что остаётся после этого разбора -Текущая реализация соответствует предыдущей спецификации и **не готова к запуску -по новой**. Следующий технический этап: получить точный русский TSQ, заменить -кандидатный PTSD-скрининг с отдельной версией, учесть изменение пропуска п.2 -RUS-AUDIT и добавить тесты. Дневник и расписания требуют отдельных задач по -слоям приложения и формализации перечисленных условий. Врачебные решения, -независимая проверка перевода и операционная проверка линий не выполнены. +Техническая реализация новой версии подготовлена в PR #53. Полное описание — +[IMPLEMENTATION.md](../IMPLEMENTATION.md). Клиническое включение остаётся закрытым: +врачебные решения, независимая проверка перевода и операционная проверка линий +не выполнены; отдельная русская форма Since Last Contact не получена. Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1) через веб-инструмент не дала доступного содержимого; актуальность редакции diff --git a/docs/clinical/tsq-guideline-2023.pdf b/docs/clinical/tsq-guideline-2023.pdf new file mode 100644 index 0000000..3968810 Binary files /dev/null and b/docs/clinical/tsq-guideline-2023.pdf differ diff --git a/docs/clinical/tsq-source.json b/docs/clinical/tsq-source.json new file mode 100644 index 0000000..3d416af --- /dev/null +++ b/docs/clinical/tsq-source.json @@ -0,0 +1,13 @@ +{ + "source_url": "https://emdr.ru/sites/default/files/2024-11/%D0%9A%D0%A0753%20%282%29.pdf", + "publisher": "Российское общество психиатров", + "guideline": "753, 2023", + "pdf": "tsq-guideline-2023.pdf", + "sha256": "d1148603af1114aa588ef206dc1cb00613acd9f19f150983d24bbcc9d50b3a5a", + "pages": [ + 129, + 130 + ], + "current_edition_verified": false, + "rop_download_6016": "Contains a draft with year 202_; not treated as approved current edition." +} diff --git a/tests/test_db.py b/tests/test_db.py index 500dc29..c8fd10f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -67,6 +67,9 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None: "safety_plans", "clock_state", "audit_log", + "questionnaire_records", + "questionnaire_notices", + "questionnaire_tasks", } == clinical_tables await init_db() # idempotent diff --git a/tests/test_dialog_intake.py b/tests/test_dialog_intake.py new file mode 100644 index 0000000..37714eb --- /dev/null +++ b/tests/test_dialog_intake.py @@ -0,0 +1,407 @@ +"""Synthetic released-config integration tests. Production approvals stay empty.""" + +import asyncio +import shutil +from datetime import UTC, datetime, timedelta + +import pytest +import yaml +from sqlalchemy import select + +from app.config import get_settings +from app.core import clock +from app.core.scales import clear_cache +from app.db import repo, session +from app.db.bootstrap import init_db +from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask +from app.dialog.contracts import Event +from app.dialog.engine import Engine +from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations +from app.llm.gateway import LLMGateway +from app.llm.mock import MockTasks +from app.scheduler import jobs +from app.services import questionnaire as service + +TOKEN = "b" * 32 +NOW = datetime(2026, 10, 3, 17, tzinfo=UTC) + + +@pytest.fixture +async def released(tmp_path, monkeypatch): + config = tmp_path / "config" + shutil.copytree(get_settings().config_dir, config) + for name in ("questionnaire", "intake_runtime"): + path = config / f"{name}.yaml" + data = yaml.safe_load(path.read_text()) + approval = dict( + reviewer="Synthetic test", + decision_reference="fixture-only", + approved_at=NOW.isoformat(), + policy_version=data["version"], + ) + data["approval"] = approval + if name == "questionnaire": + data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1) + else: + data.update(enabled=True, content_review=approval, contact_review=approval) + path.write_text(yaml.safe_dump(data, allow_unicode=True)) + monkeypatch.setattr(get_settings(), "config_dir", config) + monkeypatch.setattr(clock, "now", lambda: NOW) + monkeypatch.setattr(integrations, "notifier", MemoryNotifier()) + monkeypatch.setattr(integrations, "outbox", MemoryOutbox()) + clear_cache() + 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="synthetic-doctor", modules=["veteran"], sex="m") + await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=NOW) + yield Engine(gateway=LLMGateway(MockTasks())) + await session.configure() + clear_cache() + + +async def rows(cls=QuestionnaireRecord): + async with session.get_clinical_session() as s: + return list((await s.scalars(select(cls))).all()) + + +async def press(e, data): + return await e.handle(TOKEN, Event(type="button", button=data)) + + +def answer_button(replies, choices=None): + choices = choices or {} + for reply in reversed(replies): + for line in reply.buttons: + for b in line: + if b.data.startswith("iq:"): + item = b.data.split(":")[2] + index = choices.get(item, 1 if item.startswith("cssrs_") else 0) + for line2 in reply.buttons: + for other in line2: + if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"): + return other.data + return None + + +async def complete_intake(e, choices=None): + replies = await press(e, "intake:start") + for _ in range(100): + button = answer_button(replies, choices) + if button is None: + return replies + replies = await press(e, button) + raise AssertionError("intake did not terminate") + + +async def test_minimal_intake_raw_data_not_imputed_and_restart(released): + await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + (row,) = await rows() + assert row.status == "complete" + assert row.answers["trauma"] == 0 and not any(k.startswith("tsq_") for k in row.answers) + assert row.details["totals"]["tsq"]["total"] is None + assert row.details["totals"]["phq9"]["total"] is None + assert row.details["totals"]["rus_audit_s"]["total"] == 0 + assert row.details["totals"]["cssrs_recent"]["risk"] == "none" + assert row.details["decision"]["primary_route"] == "self_help" + + +async def test_pause_restart_and_duplicate_callbacks(released): + replies = await press(released, "intake:start") + old = answer_button(replies) + await asyncio.gather(press(released, old), press(released, old)) + (row,) = await rows() + assert row.answers == {"cssrs_q1": 0} + await press(released, "intake:pause") + restarted = Engine(gateway=LLMGateway(MockTasks())) + replies = await press(restarted, "intake:resume") + assert "cssrs_q2" in answer_button(replies) + await press(restarted, old) + assert (await rows())[0].answers == {"cssrs_q1": 0} + assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) + + +@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"]) +async def test_immediate_stop_preserves_partial_answers(released, signal): + replies = await press(released, "intake:start") + if signal == "help": + replies = await press(released, "intake:help") + elif signal == "cssrs": + for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): + replies = await press(released, answer_button(replies, {item: 0})) + else: + replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1}) + assert any(r.kind == "crisis" for r in replies) + (row,) = await rows() + assert row.status == "interrupted" and row.completed_at is None + notices = await rows(QuestionnaireNotice) + assert len(notices) == 1 and notices[0].status == "test_sink" + assert "уведомлён" not in replies[0].text + + +async def complete_existing(e, replies, choices): + for _ in range(100): + button = answer_button(replies, choices) + if not button: + return replies + replies = await press(e, button) + raise AssertionError("not terminated") + + +async def test_full_tsq_expands_at_six_and_carries_alcohol_answers(released): + choices = {"tsq_" + str(i): 0 if i <= 6 else 1 for i in range(1, 11)} + choices.update(rus_audit_3=1, rus_audit_1=1, tbi=1, psychotropics=1) + await complete_intake(released, choices) + (row,) = await rows() + assert row.status == "complete" + assert row.details["totals"]["tsq"]["total"] == 6 + assert row.details["totals"]["pcl5"]["total"] == 0 + assert row.details["totals"]["rus_audit"]["complete"] + assert row.answers["rus_audit_3"] == 1 + assert len([k for k in row.answers if k.startswith("rus_audit_")]) == 10 + + +async def test_alcohol_conflict_keeps_positive_short_answer(released): + await complete_intake(released, {"trauma": 1, "rus_audit_3": 1, "rus_audit_1": 0}) + (row,) = await rows() + assert row.status == "review_required" and row.answers["rus_audit_3"] == 1 + assert row.details["stop_reason"] == "answer_conflict" + + +async def test_low_risk_shows_safety_plan_but_moderate_stops(released): + replies = await press(released, "intake:start") + replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) + 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 "phq9_1" in answer_button(replies) + + +async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch): + class Failing: + async def send(self, *args, **kwargs): + raise OSError("sensitive error content must not be stored") + + monkeypatch.setattr(integrations, "notifier", Failing()) + await press(released, "intake:start") + await press(released, "intake:help") + (notice,) = await rows(QuestionnaireNotice) + assert notice.status == "pending" and notice.error_code == "OSError" and notice.attempts == 1 + monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(minutes=3)) + notifier = MemoryNotifier() + monkeypatch.setattr(integrations, "notifier", notifier) + await service.dispatch_notices() + await service.dispatch_notices() + assert len(notifier.sent) == 1 + assert (await rows(QuestionnaireNotice))[0].attempts == 2 + await press(released, "consent:revoke") + async with session.get_clinical_session() as s: + state = await repo.get_dialog_state(s, TOKEN) + assert state.data["consent"] is False and "questionnaire_record" not in state.data + + +async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeypatch): + await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + + class Failing: + async def push(self, *args): + raise OSError() + + monkeypatch.setattr(integrations, "outbox", Failing()) + await jobs.run_until_now() + tasks = await rows(QuestionnaireTask) + assert len(tasks) == 2 + risk = next(t for t in tasks if t.kind == "cssrs_since_contact") + assert risk.delivery and risk.sent_at is None + outbox = MemoryOutbox() + monkeypatch.setattr(integrations, "outbox", outbox) + await jobs.run_until_now() + await jobs.run_until_now() + assert len(await rows(QuestionnaireTask)) == 2 + assert len(outbox.drain(TOKEN)) == 1 + assert len(await rows()) == 2 + + +async def test_missing_since_contact_is_manual_review_not_recent(released): + async with session.get_clinical_session() as s: + task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW) + task_id = task.id + replies = await released.handle( + TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) + ) + (row,) = await rows() + assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers + assert row.details["stop_reason"] == "cssrs_since_contact_required" + assert not answer_button(replies) + + +async def test_disabled_or_no_adult_confirmation_cannot_start(released): + async with session.get_clinical_session() as s: + state = await repo.get_dialog_state(s, TOKEN) + state.data = {"consent": True} + await press(released, "intake:start") + assert await rows() == [] + + +async def review_risk_record(e): + from app.api.questionnaire import ReviewIn, review + + risk = next(r for r in reversed(await rows()) if r.kind == "cssrs_since_contact") + return await review( + TOKEN, + risk.id, + ReviewIn( + doctor_id="synthetic-doctor", assessment_performed=True, decision="resume", reference="synthetic/assessment" + ), + ) + + +async def test_doctor_review_is_audited_and_unblocks_diary(released): + from fastapi import HTTPException + + from app.api.questionnaire import ReviewIn, history, review + from app.db.audit import AuditLog + + await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + await jobs.run_until_now() + risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") + with pytest.raises(HTTPException) as exc: + await review( + TOKEN, + risk.id, + ReviewIn(doctor_id="other-doctor", assessment_performed=True, decision="resume", reference="test"), + ) + assert exc.value.status_code == 404 + await released.handle(TOKEN, Event(type="system", system="doctor_ack")) + async with session.get_clinical_session() as s: + assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required") + await review_risk_record(released) + await jobs.run_until_now() + assert any(r.kind == "diary" and r.status == "active" for r in await rows()) + await history(TOKEN, "synthetic-doctor") + audits = await rows(AuditLog) + assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"} + + +async def test_diary_numeric_skip_and_not_prescribed(released): + 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") + for _ in range(30): + q = next( + (b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")), + None, + ) + if q == "comment": + button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip")) + replies = await press(released, button) + elif q: + replies = await press(released, answer_button(replies, {"medication": 3})) + elif any("часов" in r.text for r in replies): + invalid = await released.handle(TOKEN, Event(type="text", text="nan")) + assert any("число часов" in r.text for r in invalid) + replies = await released.handle(TOKEN, Event(type="text", text="6,5")) + else: + break + 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): + await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + async with session.get_clinical_session() as s: + (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True + await press(released, "menu:checkin") + await review_risk_record(released) + replies = await press(released, "menu:checkin") + 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(b.data == "safety_plan" for r in replies for line in r.buttons for b in line) + diary = next(r for r in await rows() if r.kind == "diary") + assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted" + + +async def test_unknown_sex_goes_to_review_without_a_zone(released): + async with session.get_clinical_session() as s: + (await repo.get_profile(s, TOKEN)).sex = None + await complete_intake(released, {"trauma": 1, "sex": 2}) + (row,) = await rows() + assert row.status == "review_required" and row.answers["sex"] == "unknown" + + +async def test_version_change_blocks_resume_and_preserves_answers(released): + replies = await press(released, "intake:start") + await press(released, answer_button(replies)) + await press(released, "intake:pause") + path = get_settings().config_dir / "instruments/intake_forms.yaml" + data = yaml.safe_load(path.read_text()) + data["version"] = "synthetic-next-version" + path.write_text(yaml.safe_dump(data, allow_unicode=True)) + await press(released, "intake:resume") + (row,) = await rows() + assert row.status == "review_required" and row.answers == {"cssrs_q1": 0} + + +async def test_followup_delta_uses_comparable_complete_baseline(released): + await complete_intake(released, {"trauma": 1, "phq9_1": 2, "tbi": 1, "psychotropics": 1}) + async with session.get_clinical_session() as s: + task = await service.queue_task(s, TOKEN, "phq9", "synthetic-repeat", NOW) + task_id = task.id + replies = await released.handle( + TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) + ) + replies = await complete_existing(released, replies, {"phq9_1": 3, "phq9_2": 3, "phq9_3": 1}) + repeat = next(r for r in await rows() if r.kind == "phq9") + assert repeat.status == "complete" and repeat.details["delta"]["value"] == 5 + assert repeat.details["delta"]["retriage"] is True + + +async def test_silence_uses_completed_checkins_not_empty_records(released, monkeypatch): + await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + async with session.get_clinical_session() as s: + (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True + monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) + await jobs.plan() + await jobs.plan() + notices = await rows(QuestionnaireNotice) + assert len([n for n in notices if n.event_key.startswith("silence:")]) == 1 + + +async def test_stop_any_policy_interrupts_first_positive(released): + path = get_settings().config_dir / "questionnaire.yaml" + data = yaml.safe_load(path.read_text()) + data["cssrs_positive_policy"] = "stop_any" + path.write_text(yaml.safe_dump(data, allow_unicode=True)) + replies = await press(released, "intake:start") + replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) + assert any(r.kind == "crisis" for r in replies) + assert (await rows())[0].answers == {"cssrs_q1": 1} + + +async def test_evening_schedule_uses_moscow_date(released, monkeypatch): + await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59)) + await jobs.plan() + assert await rows(QuestionnaireTask) == [] + monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0)) + await jobs.plan() + assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"} + + +async def test_resumed_answer_keeps_original_period(released, monkeypatch): + replies = await press(released, "intake:start") + await press(released, answer_button(replies)) + await press(released, "intake:pause") + monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1)) + replies = await press(released, "intake:resume") + await press(released, answer_button(replies)) + (record,) = await rows() + metadata = record.details["answer_metadata"]["cssrs_q2"] + assert metadata["period_start"] == "2026-09-03" + assert metadata["period_end"] == "2026-10-03" + assert metadata["answered_at"].startswith("2026-10-04") diff --git a/tests/test_intake_runtime.py b/tests/test_intake_runtime.py new file mode 100644 index 0000000..5924c84 --- /dev/null +++ b/tests/test_intake_runtime.py @@ -0,0 +1,110 @@ +"""Synthetic exhaustive scoring and calendar/diary boundary checks.""" + +from datetime import date, timedelta +from itertools import product + +import pytest + +from app.core.intake_runtime import diary_flags, due_scales, load_runtime, month_before +from app.core.questionnaire import IntakePolicy, alcohol_expansion, load_policy, score_alcohol +from app.core.scales import score + + +def test_release_is_closed(): + assert not load_runtime().released + assert load_policy().approval is None + + +def test_all_tsq_patterns(): + for answers in product((0, 1), repeat=10): + result = score("tsq", {f"tsq_{i + 1}": v for i, v in enumerate(answers)}) + assert result.complete and result.total == sum(answers) + assert (result.severity_code == "positive") == (sum(answers) >= 6) + + +@pytest.mark.parametrize( + "day,expected", + [ + (date(2024, 3, 31), date(2024, 2, 29)), + (date(2025, 3, 31), date(2025, 2, 28)), + (date(2026, 1, 1), date(2025, 12, 1)), + ], +) +def test_calendar_month(day, expected): + assert month_before(day) == expected + + +def test_medication_schedule_overlapping_and_delayed_completion(): + start = date(2026, 1, 1) + last = {} + for day in (6, 7, 13, 14, 27, 28, 55, 56): + due = due_scales( + today=start + timedelta(days=day), enrolled=start, last=last, route="self_help", medication_changed=start + ) + assert ("phq9" in due) == (day in (7, 14, 28, 56)) + if "phq9" in due: + last.update(phq9=start + timedelta(days=day), pcl5=start + timedelta(days=day)) + # A missed tick yields one form, not each historical milestone. + assert ( + due_scales( + today=start + timedelta(days=80), enrolled=start, last={}, route="self_help", medication_changed=start + ).count("phq9") + == 1 + ) + assert "phq9" in due_scales( + today=start + timedelta(days=7), enrolled=start, last={}, route="specialist", after_crisis=start + ) + + +def test_diary_consecutive_days_and_baseline_do_not_include_bad_window(): + today = date(2026, 10, 3) + history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)} + for i in range(3): + history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"} + assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"} + 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(): + today = date(2026, 10, 3) + h = { + today: {"nightmares": 3}, + today - timedelta(days=1): {"sleep_hours": 3.5}, + today - timedelta(days=2): {"sleep_hours": 3.5}, + } + assert "sleep_review" in diary_flags(h, today) + h[today - timedelta(days=1)]["sleep_hours"] = 4 + assert "sleep_review" not in diary_flags(h, today) + + +def test_total_threshold_and_zone_are_separate_decisions(): + data = load_policy().model_dump() + data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1) + data["approval"] = dict( + reviewer="Synthetic fixture", + decision_reference="test", + approved_at="2026-10-03T00:00:00Z", + policy_version=data["version"], + ) + policy = IntakePolicy.model_validate(data) + r = score_alcohol({"rus_audit_3": 1, "rus_audit_9": 0, "rus_audit_10": 0}, short=True, sex="m") + assert r.zone == 1 and alcohol_expansion(r, policy) + with pytest.raises(ValueError): + IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2}) + + +def test_module_rotation_never_drops_suicide_question(): + from app.core.intake_questions import diary_questions + + first = { + q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100) + } + second = { + q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101) + } + assert "death_thoughts" in first & second + assert "nightmares" in first and "nightmares" not in second + assert "burden" in first and "burden" not in second diff --git a/tests/test_questionnaire.py b/tests/test_questionnaire.py index b0fde7d..77bd2ec 100644 --- a/tests/test_questionnaire.py +++ b/tests/test_questionnaire.py @@ -35,7 +35,7 @@ def zeros(scale): def intake(**changes): args = dict( phq9={"phq9_1": 0, "phq9_2": 0}, - pcptsd5={}, + tsq={}, pcl5={}, alcohol=zeros("rus_audit_s"), cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0}, @@ -196,10 +196,10 @@ def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative(): r = intake(phq9={"phq9_1": 1, "phq9_2": 1}) assert r.status == "incomplete" and "phq9_9" in r.missing assert intake(trauma_exposure=None).status == "incomplete" - assert intake(trauma_exposure=True, pcptsd5={"pcptsd5_1": 1}).status == "incomplete" - pc = {f"pcptsd5_{i}": int(i <= 3) for i in range(1, 6)} - assert intake(trauma_exposure=True, pcptsd5=pc).status == "incomplete" - assert intake(trauma_exposure=True, pcptsd5=pc, pcl5=zeros("pcl5")).primary_route == "psychologist" + assert intake(trauma_exposure=True, tsq={"tsq_1": 1}).status == "incomplete" + pc = {f"tsq_{i}": int(i <= 6) for i in range(1, 11)} + assert intake(trauma_exposure=True, tsq=pc).status == "incomplete" + assert intake(trauma_exposure=True, tsq=pc, pcl5=zeros("pcl5")).primary_route == "psychologist" @pytest.mark.parametrize( @@ -238,11 +238,12 @@ def test_alcohol_expansion_and_unknown_sex_block_routine_route(): assert r.status == "incomplete" and "alcohol_scoring_sex" in r.missing -def test_full_non_drinker_derivation_also_satisfies_short_stage(): +def test_non_drinker_does_not_derive_missing_short_answer(): values = zeros("rus_audit") del values["rus_audit_2"] del values["rus_audit_3"] - assert intake(alcohol=values).status == "ready" + assert intake(alcohol=values).status == "incomplete" + assert "rus_audit_3" in intake(alcohol=values).missing def test_crisis_keeps_other_flags_and_bad_data_cannot_hide_explicit_signal(): diff --git a/tests/test_safety.py b/tests/test_safety.py index d2cfeb4..90e5d8d 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None: assert isinstance(script, CrisisScript) assert script.variant == "patient.default" assert "112" in script.text - assert "8-800-2000-122" in script.text - assert "бесплатно, круглосуточно" in script.text - assert "уведомлён" in script.text + assert "+7 (495) 989-50-50" in script.text + assert "круглосуточно" in script.text + assert "по тарифу оператора" in script.text + assert "уведомлён" not in script.text assert script.grounding in script.text assert "психиатр (#8)" in script.status @@ -169,7 +170,7 @@ def test_crisis_reply_patient_default() -> None: def test_crisis_reply_variants() -> None: dv = crisis_reply(["domestic_violence", "suicide"], audience="family") assert dv.variant == "family.domestic_violence" - assert "потерпеть" in dv.text and "112" in dv.text and "8-800-2000-122" in dv.text + assert "потерпеть" in dv.text and "112" in dv.text and "+7 (495) 989-50-50" in dv.text assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence" assert crisis_reply(["harm_others"]).variant == "patient.harm_others" assert crisis_reply(["harm_others"], audience="family").variant == "family.default" diff --git a/tests/test_scoring.py b/tests/test_scoring.py index eed5f99..987fb26 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -30,7 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str: def test_all_scales_load_with_expected_item_counts() -> None: counts = {k: len(v.items) for k, v in load_all().items()} expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5} - expected.update(rus_audit_s=3, rus_audit=10) + expected.update(rus_audit_s=3, rus_audit=10, tsq=10) assert counts == expected