feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s

Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable.

Refs #52
This commit is contained in:
Pavel Eliseev
2026-10-03 18:19:34 +03:00
parent 8b61eecf1b
commit c42ef621e5
34 changed files with 2556 additions and 85 deletions
+98
View File
@@ -0,0 +1,98 @@
"""Audited questionnaire review for the synthetic prototype.
Like the existing API, doctor_id is supplied by the caller; production must put
these routes behind authenticated staff identity. No clinical data cross into identity.
"""
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.core import clock
from app.db import repo
from app.db.audit import audit
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.services import questionnaire as service
router = APIRouter(tags=["questionnaire-review"])
class ReviewIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1)
assessment_performed: Literal[True]
decision: Literal["resume", "keep_hold"]
reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$")
async def require_doctor(session, token, doctor_id):
profile = await repo.get_profile(session, token)
if profile is None or profile.doctor_id != doctor_id:
raise HTTPException(404, "patient not found")
@router.get("/patients/{token}/questionnaires")
async def history(token: str, doctor_id: str):
async with get_clinical_session() as session:
await require_doctor(session, token, doctor_id)
rows = await service.records(session, token)
await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now())
return [
{
"id": r.id,
"kind": r.kind,
"status": r.status,
"version": r.version,
"policy_version": r.policy_version,
"started_at": r.started_at,
"completed_at": r.completed_at,
"answers": r.answers,
"details": r.details,
}
for r in rows
]
@router.post("/patients/{token}/questionnaires/{record_id}/review")
async def review(token: str, record_id: str, body: ReviewIn):
now = clock.now()
async with get_clinical_session() as session:
await service.lock_patient(session, token)
await require_doctor(session, token, body.doctor_id)
state = await repo.get_dialog_state(session, token)
row = await session.get(QuestionnaireRecord, record_id)
if row is None or row.token != token:
raise HTTPException(404, "record not found")
if not state or state.data.get("consent") is not True:
raise HTTPException(409, "consent required")
if row.status not in ("review_required", "interrupted"):
raise HTTPException(409, "record does not require review")
if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"):
raise HTTPException(409, "close the acute episode after assessment before resuming routine work")
detail = {
"doctor_id": body.doctor_id,
"decision": body.decision,
"reference": body.reference,
"at": now.isoformat(),
}
row.details = {**row.details, "clinician_review": detail}
if body.decision == "resume":
row.status = "reviewed"
remaining = [
r
for r in await service.records(session, token)
if r.id != row.id and r.status in ("review_required", "interrupted")
]
if not remaining:
state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None}
await audit(
session,
actor=body.doctor_id,
action="review_questionnaire",
token=token,
details={"record_id": row.id, "decision": body.decision, "reference": body.reference},
ts=now,
)
return {"id": row.id, "status": row.status, "review": detail}
+2
View File
@@ -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 fastapi import APIRouter, HTTPException
from app.api import fixtures from app.api import fixtures
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
AckIn, AckIn,
AlertOut, AlertOut,
@@ -26,6 +27,7 @@ from app.dialog.contracts import get_engine
from app.integrations.base import MemoryOutbox, integrations from app.integrations.base import MemoryOutbox, integrations
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router)
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
+141
View File
@@ -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)
+153
View File
@@ -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)
+14 -11
View File
@@ -41,9 +41,10 @@ class IntakePolicy(BaseModel):
version: str = Field(min_length=1) version: str = Field(min_length=1)
approval: Approval | None = None approval: Approval | None = None
cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending" 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) 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) 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_psychologist_at: int = Field(ge=0, le=27, strict=True)
phq9_specialist_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) phq9_priority_at: int = Field(ge=0, le=27, strict=True)
@@ -56,9 +57,11 @@ class IntakePolicy(BaseModel):
if self.approval and ( if self.approval and (
self.approval.policy_version != self.version self.approval.policy_version != self.version
or self.cssrs_positive_policy == "pending" 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") 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: if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered") raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at: 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"): for item in ("rus_audit_2", "rus_audit_3"):
if values.get(item, 0) != 0: if values.get(item, 0) != 0:
raise ValueError("never drinks conflicts with positive consumption answers") 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 values[item] = 0
derived.append(item) derived.append(item)
result = score(scale_id, values, sex=sex) 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") raise ValueError("expansion requires RUS-AUDIT-S")
if not policy.approval or not result.complete or result.zone is None: if not policy.approval or not result.complete or result.zone is None:
return 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 assert policy.rus_audit_s_expand_from_zone is not None
return result.zone >= policy.rus_audit_s_expand_from_zone return result.zone >= policy.rus_audit_s_expand_from_zone
@@ -203,7 +208,7 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics,
zone = full.zone if full.complete else short.zone zone = full.zone if full.complete else short.zone
if zone is not None and zone >= 2: if zone is not None and zone >= 2:
flags.append("alcohol_zone_ii_or_higher") flags.append("alcohol_zone_ii_or_higher")
if psychotropics: if psychotropics and (any(v > 0 for v in alcohol.values())):
flags.append("alcohol_psychotropics") flags.append("alcohol_psychotropics")
if full.zone == 4: if full.zone == 4:
flags.append("alcohol_zone_iv") flags.append("alcohol_zone_iv")
@@ -217,7 +222,7 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics,
def evaluate_intake( def evaluate_intake(
*, *,
phq9: dict[str, int], phq9: dict[str, int],
pcptsd5: dict[str, int], tsq: dict[str, int],
pcl5: dict[str, int], pcl5: dict[str, int],
alcohol: dict[str, int], alcohol: dict[str, int],
cssrs: 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) return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
phq = score("phq9", phq9) phq = score("phq9", phq9)
pc = score("pcptsd5", pcptsd5) pc = score("tsq", tsq)
pcl = score("pcl5", pcl5) pcl = score("pcl5", pcl5)
full_alcohol = score_alcohol(alcohol, short=False, sex=sex) full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids} short_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) short_alcohol = score_alcohol(short_values, short=True, sex=sex)
missing = list(c.missing) missing = list(c.missing)
phq2_items = ("phq9_1", "phq9_2") 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) missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if trauma_exposure is None: if trauma_exposure is None:
missing.append("trauma_exposure") 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") 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: 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: if pc_positive and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5) 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. # Partially administered optional scales also cannot be treated as completed negatives.
+1
View File
@@ -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 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.clinical import ClinicalBase
from app.db.identity import IdentityBase from app.db.identity import IdentityBase
from app.db.session import clinical_engine, identity_engine from app.db.session import clinical_engine, identity_engine
+55
View File
@@ -0,0 +1,55 @@
"""Versioned questionnaire records, without modifying historical scale results."""
from datetime import date, datetime
from typing import Any
from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import BigId, ClinicalBase
from app.db.types import JSONType, TZDateTime
class QuestionnaireRecord(ClinicalBase):
__tablename__ = "questionnaire_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
token: Mapped[str] = mapped_column(String(32), index=True)
kind: Mapped[str] = mapped_column(String(32))
version: Mapped[str] = mapped_column(String(64))
policy_version: Mapped[str] = mapped_column(String(64))
started_at: Mapped[datetime] = mapped_column(TZDateTime)
completed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
day: Mapped[date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(32))
answers: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
# Per-field time, recall period, source and derived values; totals may be null.
details: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
class QuestionnaireNotice(ClinicalBase):
__tablename__ = "questionnaire_notices"
__table_args__ = (UniqueConstraint("token", "event_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
event_key: Mapped[str] = mapped_column(String(128))
alert_id: Mapped[int] = mapped_column(ForeignKey("alerts.id"))
created_at: Mapped[datetime] = mapped_column(TZDateTime)
attempted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
accepted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
attempts: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(32), default="pending")
error_code: Mapped[str | None] = mapped_column(String(80))
class QuestionnaireTask(ClinicalBase):
__tablename__ = "questionnaire_tasks"
__table_args__ = (UniqueConstraint("token", "task_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
task_key: Mapped[str] = mapped_column(String(128))
kind: Mapped[str] = mapped_column(String(32))
due_at: Mapped[datetime] = mapped_column(TZDateTime)
status: Mapped[str] = mapped_column(String(32), default="pending")
record_id: Mapped[str | None] = mapped_column(String(32))
delivery: Mapped[list] = mapped_column(JSONType, default=list)
sent_at: Mapped[datetime | None] = mapped_column(TZDateTime)
+1 -1
View File
@@ -4,7 +4,7 @@ Decisions:
* The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per * 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 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: 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): * 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. 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 — * Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message —
+52 -1
View File
@@ -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). Logs carry only the token, states and event codes (guardrail #8).
""" """
import asyncio
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock, safety from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub from app.core.privacy import scrub
from app.core.safety import SafetyResult, crisis_reply from app.core.safety import SafetyResult, crisis_reply
from app.core.types import RiskLevel, ScrubbedText 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.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.dialog.texts import DialogTexts, load_dialog_texts
from app.llm.gateway import LLMGateway, get_gateway from app.llm.gateway import LLMGateway, get_gateway
from app.services import questionnaire as intake_service
from app.services.contracts import MonitoringService, get_monitoring from app.services.contracts import MonitoringService, get_monitoring
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request. # Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
MAX_ENTER_CHAIN = 8 MAX_ENTER_CHAIN = 8
_patient_locks = WeakValueDictionary()
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface). # DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
_DB_DOWN = (OSError, OperationalError, InterfaceError) _DB_DOWN = (OSError, OperationalError, InterfaceError)
@@ -76,8 +81,13 @@ class Engine:
async def handle(self, token: str, event: Event) -> list[Reply]: async def handle(self, token: str, event: Event) -> list[Reply]:
texts = load_dialog_texts() texts = load_dialog_texts()
try: try:
lock = _patient_locks.setdefault(token, asyncio.Lock())
async with lock:
async with get_clinical_session() as session: async with get_clinical_session() as session:
return await self._handle(token, event, session, texts) 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: except _DB_DOWN as exc:
log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__) log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__)
return _degraded(event, texts) 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 session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
) )
await self._save_state(ctx) 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( log.info(
"dialog token=%s event=%s code=%s state=%s->%s replies=%d", "dialog token=%s event=%s code=%s state=%s->%s replies=%d",
token, token,
@@ -143,6 +160,12 @@ class Engine:
if event.type == "button": if event.type == "button":
if consented and event.button == "consent:revoke": if consented and event.button == "consent:revoke":
return await revoke(ctx) 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: if consented:
await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client
return await self._dispatch(ctx, event) return await self._dispatch(ctx, event)
@@ -153,6 +176,14 @@ class Engine:
if not consented: if not consented:
return Step() # nothing is pushed to people without consent return Step() # nothing is pushed to people without consent
if event.system == "scale_due" and event.params.get("scale"): 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)) scheduled = bool(event.params.get("scheduled", True))
pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled) pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled)
return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step return 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: if result.level == RiskLevel.ACUTE:
handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script" 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) log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
step = await urgent(ctx, row, code="safety_acute", categories=result.categories)
else:
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}") step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
step.data["safety_events"] = self._safety_events(ctx, result, handled) step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True # lane B escalates to the doctor on call step.notify_monitoring = True # lane B escalates to the doctor on call
return step 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: if result.level == RiskLevel.POSSIBLE:
screen = ctx.texts.settings.risk_screen_scale screen = ctx.texts.settings.risk_screen_scale
if self._can_screen(ctx, screen): if self._can_screen(ctx, screen):
@@ -252,6 +301,8 @@ class Engine:
async def _start_pending(self, ctx: Ctx) -> Step: 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.""" """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 []) pending = list(ctx.data.get("pending_scales") or [])
if not pending or ctx.state not in RESTING_STATES: if not pending or ctx.state not in RESTING_STATES:
return Step() return Step()
+24
View File
@@ -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.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")], [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) 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 (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
profile = await ctx.profile() profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else [] modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
return "intake.start"
if after in modules: if after in modules:
modules = modules[modules.index(after) + 1 :] modules = modules[modules.index(after) + 1 :]
for module in modules: for module in modules:
@@ -275,6 +293,8 @@ async def _reach_out(ctx: Ctx, event: Event) -> Step:
def close_crisis(ctx: Ctx) -> Step: def close_crisis(ctx: Ctx) -> Step:
"""Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards.""" """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()} crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()}
scales = { scales = {
sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s 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: async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now) 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 = { data = {
"questionnaire_record": None,
"consent": False, "consent": False,
"revoked_at": ctx.now.isoformat(), "revoked_at": ctx.now.isoformat(),
"delete_requested": True, "delete_requested": True,
+376
View File
@@ -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)
+170 -7
View File
@@ -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: async def plan():
"""Start periodic ticking (#13).""" 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: async def deliver():
"""Stop the scheduler (#13).""" 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: async def run_until_now():
"""Replay all due events up to `app.core.clock.now()` in order, idempotently (#13).""" 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
+264
View File
@@ -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())
+189
View File
@@ -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
+167
View File
@@ -0,0 +1,167 @@
version: mvp-v2-2026-10-03
forms:
phq9:
id: phq9
title: Опросник здоровья пациента (PHQ-9)
recall_period: последние 2 недели
instruction: Как часто за последние 2 недели Вас беспокоили следующие проблемы?
license_note: PHQ-9 (Pfizer) — свободное использование, разрешения не требуется.
translation_note: Стандартная русская версия PHQ-9 (MAPI). Финальную формулировку утверждает психиатр.
options:
- value: 0
label: Ни разу
- value: 1
label: Несколько дней
- value: 2
label: Более недели
- value: 3
label: Почти каждый день
items:
- id: phq9_1
text: Вам не хотелось ничего делать
- id: phq9_2
text: У Вас было плохое настроение, Вы были подавлены или испытывали чувство безысходности
- id: phq9_3
text: Вам было трудно заснуть, у Вас был прерывистый сон, или Вы слишком много спали
- id: phq9_4
text: Вы были утомлены, или у Вас было мало сил
- id: phq9_5
text: У Вас был плохой аппетит, или Вы переедали
- id: phq9_6
text: 'Вы плохо о себе думали: считали себя неудачником (неудачницей), или были в себе разочарованы, или считали,
что подвели свою семью'
- id: phq9_7
text: Вам было трудно сосредоточиться (например, на чтении газеты или на просмотре телепередач)
- id: phq9_8
text: Вы двигались или говорили настолько медленно, что окружающие это замечали? Или, наоборот, Вы были настолько
суетливы или взбудоражены, что двигались гораздо больше обычного
- id: phq9_9
text: Вас посещали мысли о том, что Вам лучше было бы умереть, или о том, чтобы причинить себе какой-нибудь
вред
buttons_only: true
range:
min: 0
max: 27
severity:
- min: 0
max: 4
code: minimal
label: Минимальная
- min: 5
max: 9
code: mild
label: Лёгкая
- min: 10
max: 14
code: moderate
label: Умеренная
- min: 15
max: 19
code: moderately_severe
label: Умеренно-тяжёлая
- min: 20
max: 27
code: severe
label: Тяжёлая
flags:
- code: phq9_item9_positive
when:
item: phq9_9
op: '>='
value: 1
pcl5:
id: pcl5
title: Контрольный список симптомов ПТСР (PCL-5)
recall_period: последний месяц
instruction: 'Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового
события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант,
который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
Насколько сильно за последний месяц Вас беспокоили:'
license_note: PCL-5 — общественное достояние (VA National Center for PTSD); русская версия — лицензия на проверке.
translation_note: Рабочий перевод команды (демо-текст). Финальный русский перевод выбирает психиатр (SPEC §9).
Для еженедельного мониторинга используется та же шкала с периодом «последняя неделя».
options:
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
items:
- id: pcl5_1
text: Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
- id: pcl5_2
text: Повторяющиеся тяжёлые сны о стрессовом событии?
- id: pcl5_3
text: Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно
вернулись туда и переживаете его заново)?
- id: pcl5_4
text: Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
- id: pcl5_5
text: Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение,
затруднённое дыхание, потливость)?
- id: pcl5_6
text: Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
- id: pcl5_7
text: Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов
или ситуаций)?
- id: pcl5_8
text: Трудности с тем, чтобы вспомнить важные части стрессового события?
- id: pcl5_9
text: Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то
серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
- id: pcl5_10
text: Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
- id: pcl5_11
text: Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
- id: pcl5_12
text: Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
- id: pcl5_13
text: Ощущение отдалённости или оторванности от других людей?
- id: pcl5_14
text: Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость
или любовь к близким людям)?
- id: pcl5_15
text: Раздражительность, вспышки гнева или агрессивное поведение?
- id: pcl5_16
text: Чрезмерный риск или поступки, которые могут причинить Вам вред?
- id: pcl5_17
text: Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
- id: pcl5_18
text: Взвинченность или склонность легко вздрагивать?
- id: pcl5_19
text: Трудности с концентрацией внимания?
- id: pcl5_20
text: Трудности с засыпанием или прерывистый сон?
range:
min: 0
max: 80
severity:
- min: 0
max: 32
code: below_threshold
label: Ниже порога ПТСР
- min: 33
max: 49
code: probable
label: Вероятное ПТСР
- min: 50
max: 80
code: high
label: Выраженная симптоматика ПТСР
flags:
- code: pcl5_probable_ptsd
when:
total: true
op: '>='
value: 33
trauma_introduction: 'Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или
травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение
или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства
или самоубийства.'
trauma_filter: Случалось ли с Вами подобное событие?
+26
View File
@@ -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
+2 -2
View File
@@ -1,10 +1,10 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol. # 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 approval: null
cssrs_positive_policy: pending cssrs_positive_policy: pending
rus_audit_s_expand_from_zone: null rus_audit_s_expand_from_zone: null
phq2_expand_at: 2 phq2_expand_at: 2
pcptsd5_expand_at: 3 tsq_expand_at: 6
phq9_psychologist_at: 5 phq9_psychologist_at: 5
phq9_specialist_at: 10 phq9_specialist_at: 10
phq9_priority_at: 20 phq9_priority_at: 20
+59
View File
@@ -0,0 +1,59 @@
id: tsq
title: Опросник на скрининг ПТСР (TSQ)
recall_period: прошлая неделя
instruction: Эта анкета связана с вашими личными реакциями на травматическое событие, которое случилось с вами.
Ниже указаны некоторые реакции, которые иногда возникают у людей после травматического события. Пожалуйста, ответьте
«Да», если вы испытывали следующие симптомы по крайней мере дважды на прошлой неделе.
translation_note: КР ПТСР 2023, ID 753, приложение Г1, с.129–130 PDF; копия emdr.ru. Опечатки сохранены. Актуальность
редакции не подтверждена.
options:
- value: 1
label: ДА
- value: 0
label: НЕТ
items:
- id: tsq_1
text: Тяжелые мысли или воспоминания о событии приходили мне в голову против моей воли
buttons_only: true
- id: tsq_2
text: Мне снились тяжелые сны о том, что со мной случилось
buttons_only: true
- id: tsq_3
text: Я вдруг замечал(а), что действую и чувствую себя так, как будто бы ситуация повторяется снова
buttons_only: true
- id: tsq_4
text: Когда что-то напоминает мне об этом событии, я чувствую себя подавленным
buttons_only: true
- id: tsq_5
text: Когда что-то напоминало мне о случившемся, я испытывал(а) неприятные физические ощущения (потливость, сбой
дыхания, тошноту, учащение пульса и др.)
buttons_only: true
- id: tsq_6
text: У меня нарушен сон (трудности засыпания или частые пробуждения)
buttons_only: true
- id: tsq_7
text: Я чувствовал(а) постоянное раздражение и гнев
buttons_only: true
- id: tsq_8
text: Мне было сложно сосредоточится
buttons_only: true
- id: tsq_9
text: Я стал более осведомлён о потенциальных опасностях для себя и других
buttons_only: true
- id: tsq_10
text: Я все время был(а) напряжен(а) и вздрагивал(а), если что-то внезапно пугало меня
buttons_only: true
range:
min: 0
max: 10
severity:
- min: 0
max: 5
code: negative
label: Ниже порога
- min: 6
max: 10
code: positive
label: Требуется уточнение
license_note: "Текст приложения Г1 клинических рекомендаций 2023 года; условия использования и актуальную редакцию подтвердить до включения."
+8 -8
View File
@@ -14,9 +14,9 @@ resources:
phone: "112" phone: "112"
note: "если есть угроза жизни прямо сейчас" note: "если есть угроза жизни прямо сейчас"
helpline: helpline:
name: "Телефон доверия" name: "Психологическая помощь МЧС России"
phone: "8-800-2000-122" phone: "+7 (495) 989-50-50"
note: "бесплатно, круглосуточно" note: "круглосуточно; стоимость звонка по тарифу оператора"
police: police:
name: "Полиция" name: "Полиция"
phone: "102" phone: "102"
@@ -30,7 +30,7 @@ variants:
себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку
и побудьте рядом с кем-то, кому доверяете. и побудьте рядом с кем-то, кому доверяете.
resources: [emergency, helpline] resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом." grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом."
patient.harm_others: patient.harm_others:
@@ -39,7 +39,7 @@ variants:
Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее. Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее.
Если кому-то угрожает опасность прямо сейчас — позвоните 112. Если кому-то угрожает опасность прямо сейчас — позвоните 112.
resources: [emergency, helpline] resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз." grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз."
patient.domestic_violence: patient.domestic_violence:
@@ -48,7 +48,7 @@ variants:
Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112. Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы и телефон доверия помогут найти безопасное место и поддержку. Кризисные службы и телефон доверия помогут найти безопасное место и поддержку.
resources: [emergency, police, helpline] resources: [emergency, police, helpline]
doctor_notified: "Ваш врач уже уведомлён." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом." grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом."
family.default: family.default:
@@ -58,7 +58,7 @@ variants:
спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите — спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите —
просто будьте рядом и слушайте. просто будьте рядом и слушайте.
resources: [emergency, helpline] resources: [emergency, helpline]
doctor_notified: "Лечащий врач уже уведомлён." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6." grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6."
family.domestic_violence: family.domestic_violence:
@@ -68,5 +68,5 @@ variants:
Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112. Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы помогут с временным убежищем и поддержкой. Кризисные службы помогут с временным убежищем и поддержкой.
resources: [emergency, police, helpline] resources: [emergency, police, helpline]
doctor_notified: "Лечащий врач уже уведомлён." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6." grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6."
+4 -4
View File
@@ -75,9 +75,9 @@ revoked:
crisis: crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы. # Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш врач уже уведомлён. Если вам угрожает опасность прямо сейчас — позвоните:" repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача). # Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Ваш врач в курсе и свяжется с вами. Если станет хуже — звоните 112 или на телефон доверия 8-800-2000-122." hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса. # Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент." closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
@@ -97,7 +97,7 @@ safety_plan:
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело." - "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти." - "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец." - "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия 8-800-2000-122, экстренные службы 112." - "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку." - "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя." means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
@@ -111,4 +111,4 @@ visit:
unavailable: |- unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже. Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия 8-800-2000-122. Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
+33
View File
@@ -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: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план."
+117
View File
@@ -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 не выполняет ни один из этих пунктов.
+12 -9
View File
@@ -10,8 +10,10 @@
Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md) Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md)
и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены
отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания. отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания.
**Код пока следует предыдущей версии, новая спецификация ещё не реализована.** **Новая спецификация подключена к диалогу за закрытым переключателем.**
Текст TSQ в новом PDF отсутствует: перечислены только темы. [Состав реализации, ограничения и порядок проверки](IMPLEMENTATION.md).
Текст TSQ получен отдельно из приложения Г1 клинических рекомендаций 2023 года;
актуальность этой редакции ещё нужно подтвердить.
Независимая проверка перевода остаётся pending. Независимая проверка перевода остаётся pending.
Описания реализованных правил и ссылки на страницы ниже относятся к исходному Описания реализованных правил и ссылки на страницы ниже относятся к исходному
14-страничному PDF, если явно не указана новая версия. 14-страничному PDF, если явно не указана новая версия.
@@ -21,9 +23,9 @@
`app/core/questionnaire.py` содержит детерминированные правила первичного опроса: `app/core/questionnaire.py` содержит детерминированные правила первичного опроса:
короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5, короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5,
условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent, условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent,
приоритет основного маршрута и параллельные направления. Это новый модуль для приоритет основного маршрута и параллельные направления. Правила подключены к версионированному диалогу, дневнику и планировщику.
интеграции после проверки, а не уже подключённый сценарий бота. Существующие Замороженные контракты, `config/modules.yaml` и исторические результаты не менялись.
контракты, `config/modules.yaml`, мониторинг и исторические результаты не менялись. Клиническое включение заблокировано до подписей.
`config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без `config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без
записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий
@@ -58,7 +60,7 @@ DOI 10.17116/profmed20222508121. Рисунок визуально провер
При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10 При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10
переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт
получения ответа в краткой форме: её пояснение травмы отличается от полного текста. получения ответа в краткой форме: её пояснение травмы отличается от полного текста.
Правило PDF `п.1 = 0` может вывести нули только для 2/3, **не для 9/10**; Правило PDF `п.1 = 0` в новой версии выводит ноль только для 2; исходный 3 сохраняется, как и 9/10;
выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется. выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется.
Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является
@@ -152,9 +154,9 @@ q6 за всю жизнь всем; при положительном q6 уто
| ID | Решение, которое нужно зафиксировать | Статус | | ID | Решение, которое нужно зафиксировать | Статус |
|---|---|---| |---|---|---|
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending | | 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 | | 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 | | C05 | Привязка PHQ-9/PCL-5 к специалистам, сроки срочной помощи, параллельные маршруты и состав флагов. | pending |
| C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending | | C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending |
| C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending | | C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending |
@@ -170,7 +172,8 @@ q6 за всю жизнь всем; при положительном q6 уто
## Подключение к диалогу ## Подключение к диалогу
Адаптер вызывает `evaluate_intake` после каждого ответа и на ручной кнопке помощи. Адаптер проверяет непосредственные сигналы после каждого ответа и на кнопке помощи;
при завершении вызывает `evaluate_intake` для общего маршрута.
`crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required` `crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required`
не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные
пропуски; `ready` содержит основной маршрут и отдельные параллельные направления. пропуски; `ready` содержит основной маршрут и отдельные параллельные направления.
+14 -5
View File
@@ -10,11 +10,20 @@
| Московская служба психологической помощи | 051 с городского; 8 (495) 051 с мобильного | Москва, региональный ресурс | [Страница службы](https://msph.ru/uslugi/neotlozhnaya-psikhologicheskaya-pomoshch): круглосуточно; городской звонок бесплатный, мобильный тарифицируется оператором. Не преобразовывать необычный короткий номер в выдуманный E.164. | | Московская служба психологической помощи | 051 с городского; 8 (495) 051 с мобильного | Москва, региональный ресурс | [Страница службы](https://msph.ru/uslugi/neotlozhnaya-psikhologicheskaya-pomoshch): круглосуточно; городской звонок бесплатный, мобильный тарифицируется оператором. Не преобразовывать необычный короткий номер в выдуманный E.164. |
| Детский телефон доверия | 8-800-2000-122 | Дети, подростки и родители по вопросам ребёнка | [Официальная страница](https://telefon-doveria.ru/about/): аудитория, анонимность и бесплатность. Не универсальная взрослая кризисная линия. Часы работы конкретного регионального подразделения нужно уточнить. | | Детский телефон доверия | 8-800-2000-122 | Дети, подростки и родители по вопросам ребёнка | [Официальная страница](https://telefon-doveria.ru/about/): аудитория, анонимность и бесплатность. Не универсальная взрослая кризисная линия. Часы работы конкретного регионального подразделения нужно уточнить. |
В существующем `config/texts/crisis.yaml` номер 8-800-2000-122 ошибочно подписан В `config/texts/crisis.yaml` универсальная взрослая ветка исправлена на линию МЧС;
просто «Телефон доверия» для всех пациентов. Взрослой ветке нужен ресурс для взрослых; детский номер сохранён в возрастной ветке с указанием аудитории. Безусловное
детский оставить с точной аудиторией. Замена номера и текстов в активном диалоге обещание уведомления врача удалено. Формулировки и готовность линий требуют
должна быть согласована с владельцем кризисного сценария и врачом (#8). согласования перед включением нового опросника.
Этот пакет проверок сам по себе активный кризисный экран не меняет.
## Дополнения из новой версии 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 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная Номер 112 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная
ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих
+11 -10
View File
@@ -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." "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, "intake_release_ready": false,
"policy_version": "mvp-review-2026-10-03", "policy_version": "mvp-v2-review-2026-10-03",
"latest_specification": { "latest_specification": {
"source_pdf": "revision-2026-10-03-v2/questionnaires-mvp-v2.pdf", "source_pdf": "revision-2026-10-03-v2/questionnaires-mvp-v2.pdf",
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e", "sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
"pages": 19, "pages": 19,
"review": "revision-2026-10-03-v2/REVIEW.md", "review": "revision-2026-10-03-v2/REVIEW.md",
"implemented": false, "implemented": true,
"pending_changes": [ "pending_changes": [],
"tsq_replaces_pcptsd5", "tsq_verbatim_source_verified": true,
"alcohol_q1_zero_derives_q2_only", "current_guideline_edition_verified": false,
"daily_diary", "implementation_scope": "gated_intake_diaries_schedules_manual_followup_risk_review",
"medication_followup_schedule" "implementation": "IMPLEMENTATION.md"
],
"tsq_verbatim_source_verified": false,
"current_guideline_edition_verified": false
}, },
"backtranslation": { "backtranslation": {
"units": 32, "units": 32,
@@ -48,5 +45,9 @@
"reconciliation": "revision-2026-10-03-v2/translation-reconciliation.tsv", "reconciliation": "revision-2026-10-03-v2/translation-reconciliation.tsv",
"clinician_approval": null, "clinician_approval": null,
"independent_review": "not_performed" "independent_review": "not_performed"
},
"cssrs_since_contact": {
"russian_source_received": false,
"fallback": "clinician_assessment_required_before_routine_monitoring"
} }
} }
+13 -12
View File
@@ -5,22 +5,25 @@
в `source-manifest.json`. Все страницы новой версии прочитаны и просмотрены. в `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. | | с.3 | Вступление и фильтр травмы PC-PTSD-5 сохранены; при «Нет» TSQ не задаётся | Записывать «не проводился по фильтру», не десять нулевых ответов. Вопрос о травме не является одиннадцатым пунктом TSQ. |
| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Требуется дословная инструкция и пункты из приложения Г1 с проверенной редакцией. | | с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Дословный текст получен из приложения Г1 редакции 2023 года; актуальность редакции ещё не подтверждена. |
| с.5–7 | Обосновано сохранение PCL-5 вместо IES-R; предложено сравнение на пилоте | 20 русских пунктов, инструкции и варианты PCL-5 не изменились; поменялось расположение на страницах. Пилот и валидация не выполнены. | | с.5–7 | Обосновано сохранение PCL-5 вместо IES-R; предложено сравнение на пилоте | 20 русских пунктов, инструкции и варианты PCL-5 не изменились; поменялось расположение на страницах. Пилот и валидация не выполнены. |
| с.8–10 | RUS-AUDIT-S исправлен на 3/9/10; повтор тоже краткой формой | Совпадает с уже реализованным составом. Временное расширение при любом ненулевом ответе не равно переходу с зоны II. Решение C02 остаётся открытым. | | с.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. Это не автоматическое утверждение порога расширения. | | с.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 его не заменяет. | | с.11–12 | Добавлена ссылка на русские бланки Columbia | Recent уже получен и сверён в PR. Для мониторинга требуется отдельный официальный Since Last Contact; текущий Recent его не заменяет. |
| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Нужны дата начала/смены, правила объединения расписаний и вопрос о переносимости. Сам вопрос о побочных эффектах пока не сформулирован. | | с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Расписания и объединение реализованы, вопрос о переносимости добавлен как кандидатный. Требуются согласование и корректные даты изменений назначений. |
| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. В текущий код не добавлены. | | с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. Добавлены за закрытым переключателем; определения и якоря ждут согласования. |
| с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. | | с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. |
| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Предыдущая проверка официальных сайтов охватывала 112, МЧС, московскую и детскую линии; новые три позиции отдельно не подтверждены. | | с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Проверка источников дополнена в ../crisis-contacts.md; проверка соединения не выполнена. |
## Противоречия и недостающие определения ## Противоречия и недостающие определения
@@ -88,12 +91,10 @@ PC-PTSD-5 и его отдельная инструкция о месяце.
## Что остаётся после этого разбора ## Что остаётся после этого разбора
Текущая реализация соответствует предыдущей спецификации и **не готова к запуску Техническая реализация новой версии подготовлена в PR #53. Полное описание —
по новой**. Следующий технический этап: получить точный русский TSQ, заменить [IMPLEMENTATION.md](../IMPLEMENTATION.md). Клиническое включение остаётся закрытым:
кандидатный PTSD-скрининг с отдельной версией, учесть изменение пропуска п.2 врачебные решения, независимая проверка перевода и операционная проверка линий
RUS-AUDIT и добавить тесты. Дневник и расписания требуют отдельных задач по не выполнены; отдельная русская форма Since Last Contact не получена.
слоям приложения и формализации перечисленных условий. Врачебные решения,
независимая проверка перевода и операционная проверка линий не выполнены.
Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1) Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1)
через веб-инструмент не дала доступного содержимого; актуальность редакции через веб-инструмент не дала доступного содержимого; актуальность редакции
Binary file not shown.
+13
View File
@@ -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."
}
+3
View File
@@ -67,6 +67,9 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
"safety_plans", "safety_plans",
"clock_state", "clock_state",
"audit_log", "audit_log",
"questionnaire_records",
"questionnaire_notices",
"questionnaire_tasks",
} == clinical_tables } == clinical_tables
await init_db() # idempotent await init_db() # idempotent
+407
View File
@@ -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")
+110
View File
@@ -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
+8 -7
View File
@@ -35,7 +35,7 @@ def zeros(scale):
def intake(**changes): def intake(**changes):
args = dict( args = dict(
phq9={"phq9_1": 0, "phq9_2": 0}, phq9={"phq9_1": 0, "phq9_2": 0},
pcptsd5={}, tsq={},
pcl5={}, pcl5={},
alcohol=zeros("rus_audit_s"), alcohol=zeros("rus_audit_s"),
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0}, 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}) r = intake(phq9={"phq9_1": 1, "phq9_2": 1})
assert r.status == "incomplete" and "phq9_9" in r.missing assert r.status == "incomplete" and "phq9_9" in r.missing
assert intake(trauma_exposure=None).status == "incomplete" assert intake(trauma_exposure=None).status == "incomplete"
assert intake(trauma_exposure=True, pcptsd5={"pcptsd5_1": 1}).status == "incomplete" assert intake(trauma_exposure=True, tsq={"tsq_1": 1}).status == "incomplete"
pc = {f"pcptsd5_{i}": int(i <= 3) for i in range(1, 6)} pc = {f"tsq_{i}": int(i <= 6) for i in range(1, 11)}
assert intake(trauma_exposure=True, pcptsd5=pc).status == "incomplete" assert intake(trauma_exposure=True, tsq=pc).status == "incomplete"
assert intake(trauma_exposure=True, pcptsd5=pc, pcl5=zeros("pcl5")).primary_route == "psychologist" assert intake(trauma_exposure=True, tsq=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
@pytest.mark.parametrize( @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 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") values = zeros("rus_audit")
del values["rus_audit_2"] del values["rus_audit_2"]
del values["rus_audit_3"] 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(): def test_crisis_keeps_other_flags_and_bad_data_cannot_hide_explicit_signal():
+5 -4
View File
@@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None:
assert isinstance(script, CrisisScript) assert isinstance(script, CrisisScript)
assert script.variant == "patient.default" assert script.variant == "patient.default"
assert "112" in script.text assert "112" in script.text
assert "8-800-2000-122" in script.text assert "+7 (495) 989-50-50" in script.text
assert "бесплатно, круглосуточно" in script.text assert "круглосуточно" in script.text
assert "уведомлён" in script.text assert "по тарифу оператора" in script.text
assert "уведомлён" not in script.text
assert script.grounding in script.text assert script.grounding in script.text
assert "психиатр (#8)" in script.status assert "психиатр (#8)" in script.status
@@ -169,7 +170,7 @@ def test_crisis_reply_patient_default() -> None:
def test_crisis_reply_variants() -> None: def test_crisis_reply_variants() -> None:
dv = crisis_reply(["domestic_violence", "suicide"], audience="family") dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
assert dv.variant == "family.domestic_violence" 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(["domestic_violence"]).variant == "patient.domestic_violence"
assert crisis_reply(["harm_others"]).variant == "patient.harm_others" assert crisis_reply(["harm_others"]).variant == "patient.harm_others"
assert crisis_reply(["harm_others"], audience="family").variant == "family.default" assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
+1 -1
View File
@@ -30,7 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
def test_all_scales_load_with_expected_item_counts() -> None: def test_all_scales_load_with_expected_item_counts() -> None:
counts = {k: len(v.items) for k, v in load_all().items()} 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 = {"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 assert counts == expected