Merge pull request 'feat(questionnaire): revised intake, diaries and follow-up monitoring' (#53) from feat/52-questionnaire into dev
CI / ruff · pytest (push) Successful in 59s
CI / ruff · pytest (push) Successful in 59s
Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
@@ -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}
|
||||
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.questionnaire import router as questionnaire_router
|
||||
from app.api.schemas import (
|
||||
AckIn,
|
||||
AlertOut,
|
||||
@@ -26,6 +27,7 @@ from app.dialog.contracts import get_engine
|
||||
from app.integrations.base import MemoryOutbox, integrations
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(questionnaire_router)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ doctor dashboard
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Russian Recent source transcription, separate from the legacy sequential scale.
|
||||
|
||||
This content does not authorize administering or continuing the questionnaire.
|
||||
Use questionnaire.score_cssrs_recent for required/missing items and risk, and
|
||||
evaluate_intake for the clinical policy gate before presenting another question.
|
||||
Never render interviewer guidance as if it were another patient question.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
ItemId = Literal["q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"]
|
||||
|
||||
|
||||
class SourceModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class CssrsOption(SourceModel):
|
||||
value: int = Field(strict=True, ge=0, le=1)
|
||||
label: str
|
||||
|
||||
|
||||
class CssrsItem(SourceModel):
|
||||
id: ItemId
|
||||
text: str = Field(min_length=1)
|
||||
interviewer_guidance: str | None
|
||||
recall_period: Literal["past_month", "lifetime", "past_3_months"]
|
||||
yes_risk: Literal["low", "moderate", "high"]
|
||||
|
||||
|
||||
class CssrsRussianForm(SourceModel):
|
||||
id: Literal["cssrs_recent_ru"]
|
||||
version: str = Field(min_length=1)
|
||||
title: str
|
||||
subtitle: Literal["Screen Version - Recent"]
|
||||
period_label_original: str
|
||||
initial_instruction: str
|
||||
branch_instruction: str
|
||||
options: tuple[CssrsOption, CssrsOption]
|
||||
items: tuple[CssrsItem, ...]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _structure(self) -> "CssrsRussianForm":
|
||||
if tuple(item.id for item in self.items) != ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"):
|
||||
raise ValueError("Recent form requires seven ordered answer fields")
|
||||
if tuple(option.value for option in self.options) != (1, 0):
|
||||
raise ValueError("source answer columns are Yes, No")
|
||||
return self
|
||||
|
||||
|
||||
def load_cssrs_russian(path: Path | None = None) -> CssrsRussianForm:
|
||||
path = path or get_settings().config_dir / "instruments" / "cssrs_recent_ru.yaml"
|
||||
return CssrsRussianForm.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Versioned intake rules for clinical review (#52).
|
||||
|
||||
No patient wording or LLM calls. This module deliberately does not replace legacy
|
||||
cssrs_screen (whose q6 only covers three months) or reinterpret historical data.
|
||||
The dialog adapter must pass RAW answers, persist the policy version, and respect
|
||||
the returned status. Pending clinical policy never authorizes routine continuation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.scales import Sex, load_scale, score
|
||||
|
||||
Risk = Literal["none", "low", "moderate", "high"]
|
||||
|
||||
|
||||
class Approval(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||
reviewer: str = Field(min_length=1)
|
||||
decision_reference: str = Field(min_length=1)
|
||||
approved_at: datetime
|
||||
policy_version: str = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _aware(self) -> Approval:
|
||||
if self.approved_at.tzinfo is None:
|
||||
raise ValueError("approval timestamp must include timezone")
|
||||
return self
|
||||
|
||||
|
||||
class IntakePolicy(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
version: str = Field(min_length=1)
|
||||
approval: Approval | None = None
|
||||
cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending"
|
||||
rus_audit_s_expand_at: int | None = Field(default=None, ge=1, le=12, strict=True)
|
||||
rus_audit_s_expand_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
|
||||
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
|
||||
tsq_expand_at: int = Field(ge=1, le=10, strict=True)
|
||||
phq9_psychologist_at: int = Field(ge=0, le=27, strict=True)
|
||||
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
|
||||
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
|
||||
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
|
||||
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
|
||||
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _approval_matches(self) -> IntakePolicy:
|
||||
if self.approval and (
|
||||
self.approval.policy_version != self.version
|
||||
or self.cssrs_positive_policy == "pending"
|
||||
or (self.rus_audit_s_expand_from_zone is None and self.rus_audit_s_expand_at is None)
|
||||
):
|
||||
raise ValueError("approval requires the matching version and resolved clinical decisions")
|
||||
if self.rus_audit_s_expand_from_zone is not None and self.rus_audit_s_expand_at is not None:
|
||||
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
|
||||
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
|
||||
raise ValueError("PHQ-9 thresholds must be ordered")
|
||||
if self.pcl5_specialist_at >= self.pcl5_priority_at:
|
||||
raise ValueError("PCL-5 thresholds must be ordered")
|
||||
return self
|
||||
|
||||
|
||||
def load_policy(path: Path | None = None) -> IntakePolicy:
|
||||
path = path or get_settings().config_dir / "questionnaire.yaml"
|
||||
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AlcoholResult:
|
||||
scale: str
|
||||
complete: bool
|
||||
total: int | None
|
||||
zone: int | None
|
||||
missing: tuple[str, ...]
|
||||
derived: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> AlcoholResult:
|
||||
"""RUS-AUDIT-S uses original items 3/9/10; unknown sex never borrows a cut-off."""
|
||||
if sex not in (None, "m", "f"):
|
||||
raise ValueError("sex must be m, f or None")
|
||||
scale_id = "rus_audit_s" if short else "rus_audit"
|
||||
# Validate BEFORE derivation: bools, floats and conflicting answers must not be hidden.
|
||||
score(scale_id, answers, sex=sex)
|
||||
values = dict(answers)
|
||||
derived = []
|
||||
if not short and values.get("rus_audit_1") == 0:
|
||||
for item in ("rus_audit_2", "rus_audit_3"):
|
||||
if values.get(item, 0) != 0:
|
||||
raise ValueError("never drinks conflicts with positive consumption answers")
|
||||
if item == "rus_audit_2" and item not in values:
|
||||
values[item] = 0
|
||||
derived.append(item)
|
||||
result = score(scale_id, values, sex=sex)
|
||||
missing = tuple(i for i in load_scale(scale_id).item_ids if i not in values)
|
||||
zone = int(result.severity_code[-1]) if result.complete and sex is not None else None
|
||||
return AlcoholResult(
|
||||
scale_id, result.complete, result.total if result.complete else None, zone, missing, tuple(derived)
|
||||
)
|
||||
|
||||
|
||||
def extend_alcohol(short_answers: dict[str, int], additional: dict[str, int]) -> dict[str, int]:
|
||||
"""Reuse original item IDs. Duplicate equal answers count once; conflicting ones fail."""
|
||||
score("rus_audit_s", short_answers)
|
||||
score("rus_audit", additional)
|
||||
for key in short_answers.keys() & additional.keys():
|
||||
if short_answers[key] != additional[key]:
|
||||
raise ValueError(f"conflicting repeated answer: {key}")
|
||||
return {**short_answers, **additional}
|
||||
|
||||
|
||||
def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | None:
|
||||
"""None means unresolved, never a negative screen or permission to skip the full form."""
|
||||
if result.scale != "rus_audit_s":
|
||||
raise ValueError("expansion requires RUS-AUDIT-S")
|
||||
if not policy.approval or not result.complete or result.zone is None:
|
||||
return None
|
||||
if policy.rus_audit_s_expand_at is not None:
|
||||
return result.total >= policy.rus_audit_s_expand_at
|
||||
assert policy.rus_audit_s_expand_from_zone is not None
|
||||
return result.zone >= policy.rus_audit_s_expand_from_zone
|
||||
|
||||
|
||||
_CSSRS_ITEMS = ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CssrsResult:
|
||||
complete: bool
|
||||
risk: Risk | None
|
||||
risk_lower_bound: Risk
|
||||
positive: bool
|
||||
required: tuple[str, ...]
|
||||
missing: tuple[str, ...]
|
||||
conflicts: tuple[str, ...]
|
||||
|
||||
|
||||
def score_cssrs_recent(answers: dict[str, int]) -> CssrsResult:
|
||||
"""Recent screener structure; q1-5 month, q6 lifetime + conditional three-month follow-up.
|
||||
|
||||
These keys describe data, NOT an official Russian translation. Positive signals
|
||||
survive missing/contradictory answers; only complete consistent data yield final risk.
|
||||
"""
|
||||
for key, value in answers.items():
|
||||
if key not in _CSSRS_ITEMS or type(value) is not int or value not in (0, 1):
|
||||
raise ValueError("C-SSRS requires known item IDs and integer 0/1 answers")
|
||||
required = ["q1", "q2"]
|
||||
if answers.get("q2") == 1:
|
||||
required.extend(["q3", "q4", "q5"])
|
||||
required.append("q6_lifetime")
|
||||
if answers.get("q6_lifetime") == 1:
|
||||
required.append("q6_recent")
|
||||
conflicts = []
|
||||
if answers.get("q2") == 0 and any(answers.get(k) == 1 for k in ("q3", "q4", "q5")):
|
||||
conflicts.append("ideation_branch_conflict")
|
||||
if answers.get("q6_lifetime") == 0 and answers.get("q6_recent") == 1:
|
||||
conflicts.append("behavior_recency_conflict")
|
||||
missing = tuple(k for k in required if k not in answers)
|
||||
complete = not missing and not conflicts
|
||||
lower: Risk = "none"
|
||||
if any(answers.get(k) == 1 for k in ("q4", "q5", "q6_recent")):
|
||||
lower = "high"
|
||||
elif answers.get("q3") == 1 or answers.get("q6_lifetime") == 1:
|
||||
lower = "moderate"
|
||||
elif any(answers.get(k) == 1 for k in ("q1", "q2")):
|
||||
lower = "low"
|
||||
return CssrsResult(
|
||||
complete, lower if complete else None, lower, 1 in answers.values(), tuple(required), missing, tuple(conflicts)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntakeDecision:
|
||||
policy_version: str
|
||||
status: Literal["crisis", "clinical_review_required", "incomplete", "ready"]
|
||||
primary_route: str | None
|
||||
parallel_routes: tuple[str, ...]
|
||||
flags: tuple[str, ...]
|
||||
missing: tuple[str, ...]
|
||||
|
||||
|
||||
def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy):
|
||||
"""Preserve independently observable flags even when the primary route is crisis.
|
||||
|
||||
Invalid unrelated data is marked for review rather than blocking a known crisis.
|
||||
The routine branch still validates every answer strictly before routing.
|
||||
"""
|
||||
flags = []
|
||||
try:
|
||||
score("pcl5", pcl5)
|
||||
if any(pcl5.get(k, 0) >= policy.pcl5_behavior_flag_at for k in ("pcl5_15", "pcl5_16")):
|
||||
flags.append("pcl5_behavior")
|
||||
except ValueError:
|
||||
flags.append("invalid_pcl5_answers")
|
||||
try:
|
||||
full = score_alcohol(alcohol, short=False, sex=sex)
|
||||
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
|
||||
short = score_alcohol(short_values, short=True, sex=sex)
|
||||
zone = full.zone if full.complete else short.zone
|
||||
if zone is not None and zone >= 2:
|
||||
flags.append("alcohol_zone_ii_or_higher")
|
||||
if psychotropics and (any(v > 0 for v in alcohol.values())):
|
||||
flags.append("alcohol_psychotropics")
|
||||
if full.zone == 4:
|
||||
flags.append("alcohol_zone_iv")
|
||||
except ValueError:
|
||||
flags.append("invalid_alcohol_answers")
|
||||
if traumatic_brain_injury:
|
||||
flags.append("traumatic_brain_injury")
|
||||
return flags
|
||||
|
||||
|
||||
def evaluate_intake(
|
||||
*,
|
||||
phq9: dict[str, int],
|
||||
tsq: dict[str, int],
|
||||
pcl5: dict[str, int],
|
||||
alcohol: dict[str, int],
|
||||
cssrs: dict[str, int],
|
||||
sex: Sex | None,
|
||||
trauma_exposure: bool | None,
|
||||
help_now: bool = False,
|
||||
traumatic_brain_injury: bool = False,
|
||||
psychotropics: bool = False,
|
||||
policy: IntakePolicy | None = None,
|
||||
) -> IntakeDecision:
|
||||
"""Two-stage candidate routing. No side effects, notifications, or claims of approval.
|
||||
|
||||
`alcohol` keeps full-scale IDs across both stages. Existing PHQ/PCL wording and
|
||||
new C-SSRS content still need release review even with a signed routing policy.
|
||||
"""
|
||||
policy = policy or load_policy()
|
||||
if type(help_now) is not bool or type(traumatic_brain_injury) is not bool or type(psychotropics) is not bool:
|
||||
raise ValueError("context flags must be bool")
|
||||
if trauma_exposure is not None and type(trauma_exposure) is not bool:
|
||||
raise ValueError("trauma_exposure must be bool or None")
|
||||
# Independent flags survive the higher-priority crisis route.
|
||||
flags = _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy)
|
||||
# A manual emergency request does not depend on the completeness of any scale.
|
||||
if help_now:
|
||||
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(["help_now", *flags]), ())
|
||||
# Act on explicit safety answers BEFORE processing unrelated, possibly invalid scales.
|
||||
phq_item9 = phq9.get("phq9_9")
|
||||
item9_positive = type(phq_item9) is int and phq_item9 in (1, 2, 3)
|
||||
try:
|
||||
c = score_cssrs_recent(cssrs)
|
||||
except ValueError:
|
||||
known_high = any(type(cssrs.get(k)) is int and cssrs[k] == 1 for k in ("q4", "q5", "q6_recent"))
|
||||
if not item9_positive and not known_high:
|
||||
raise
|
||||
flags.extend(["invalid_cssrs_answers", "phq9_item9_positive" if item9_positive else "cssrs_positive"])
|
||||
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), ())
|
||||
if item9_positive:
|
||||
flags.append("phq9_item9_positive")
|
||||
if c.complete and c.risk == "none":
|
||||
flags.append("phq9_cssrs_discrepancy")
|
||||
if c.positive:
|
||||
flags.append("cssrs_positive")
|
||||
flags.extend(c.conflicts)
|
||||
if item9_positive or c.risk_lower_bound == "high":
|
||||
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
|
||||
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
|
||||
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
|
||||
if c.positive and not policy.approval:
|
||||
return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
|
||||
|
||||
phq = score("phq9", phq9)
|
||||
pc = score("tsq", tsq)
|
||||
pcl = score("pcl5", pcl5)
|
||||
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
|
||||
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
|
||||
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
|
||||
missing = list(c.missing)
|
||||
phq2_items = ("phq9_1", "phq9_2")
|
||||
missing.extend(k for k in phq2_items if k not in phq9)
|
||||
phq2_complete = all(k in phq9 for k in phq2_items)
|
||||
phq2_positive = phq2_complete and sum(phq9[k] for k in phq2_items) >= policy.phq2_expand_at
|
||||
if phq2_positive:
|
||||
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
|
||||
if trauma_exposure is None:
|
||||
missing.append("trauma_exposure")
|
||||
if trauma_exposure is False and tsq:
|
||||
flags.append("trauma_filter_conflict")
|
||||
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.tsq_expand_at
|
||||
if trauma_exposure is True and not pc.complete:
|
||||
missing.extend(k for k in load_scale("tsq").item_ids if k not in tsq)
|
||||
if pc_positive and not pcl.complete:
|
||||
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
|
||||
# Partially administered optional scales also cannot be treated as completed negatives.
|
||||
if len(phq9) > 2 and not phq.complete:
|
||||
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
|
||||
if pcl5 and not pcl.complete:
|
||||
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
|
||||
missing.extend(short_alcohol.missing)
|
||||
if sex is None:
|
||||
missing.append("alcohol_scoring_sex")
|
||||
expand = alcohol_expansion(short_alcohol, policy)
|
||||
if expand or (len(alcohol) > 3 and not full_alcohol.complete):
|
||||
missing.extend(full_alcohol.missing)
|
||||
if expand is None:
|
||||
flags.append("alcohol_transition_unresolved")
|
||||
if not policy.approval or c.conflicts or "trauma_filter_conflict" in flags:
|
||||
return IntakeDecision(
|
||||
policy.version, "clinical_review_required", None, (), tuple(flags), tuple(dict.fromkeys(missing))
|
||||
)
|
||||
if missing:
|
||||
return IntakeDecision(policy.version, "incomplete", None, (), tuple(flags), tuple(dict.fromkeys(missing)))
|
||||
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
|
||||
phq_total = phq.total if phq.complete else 0
|
||||
pcl_total = pcl.total if pcl.complete else 0
|
||||
if c.risk == "moderate":
|
||||
primary = "urgent_psychiatrist"
|
||||
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
|
||||
primary = "priority_psychiatrist"
|
||||
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
|
||||
primary = "psychiatrist_or_psychotherapist"
|
||||
elif phq_total >= policy.phq9_psychologist_at or pc_positive:
|
||||
primary = "psychologist"
|
||||
else:
|
||||
primary = "self_help"
|
||||
parallel = []
|
||||
if full_alcohol.zone == 4:
|
||||
parallel.append("narcologist")
|
||||
if traumatic_brain_injury:
|
||||
parallel.append("neurologist")
|
||||
if c.risk == "low":
|
||||
flags.append("cssrs_low_safety_plan")
|
||||
return IntakeDecision(policy.version, "ready", primary, tuple(parallel), tuple(flags), ())
|
||||
@@ -4,6 +4,7 @@ The only module (besides registration code) that touches both schemas — and on
|
||||
"""
|
||||
|
||||
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
|
||||
from app.db import intake as _intake # noqa: F401
|
||||
from app.db.clinical import ClinicalBase
|
||||
from app.db.identity import IdentityBase
|
||||
from app.db.session import clinical_engine, identity_engine
|
||||
|
||||
@@ -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)
|
||||
@@ -4,7 +4,7 @@ Decisions:
|
||||
* The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per
|
||||
open crisis. "Open" = `data["crisis"]["open"]` — set when the crisis starts, cleared only by the doctor's
|
||||
acknowledgement (`doctor_ack`), so a patient repeating the phrase does not get the same long script again:
|
||||
they get a short fixed "я рядом, врач уже уведомлён" + emergency resources instead.
|
||||
they get a short fixed support message and emergency resources instead.
|
||||
* Entering the crisis drops unfinished scale sessions (their answered items are already stored item by item):
|
||||
nothing else is asked while the patient is in crisis, except the C-SSRS screen.
|
||||
* Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message —
|
||||
|
||||
+52
-1
@@ -22,15 +22,18 @@ Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
|
||||
Logs carry only the token, states and event codes (guardrail #8).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import clock, safety
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
from app.core.privacy import scrub
|
||||
from app.core.safety import SafetyResult, crisis_reply
|
||||
from app.core.types import RiskLevel, ScrubbedText
|
||||
@@ -45,12 +48,14 @@ from app.dialog.flows.core import close_crisis, revoke
|
||||
from app.dialog.fsm import NO_CONSENT_STATES, RESTING_STATES, Ctx, Step, apply_data, enter_hook_for, handler_for
|
||||
from app.dialog.texts import DialogTexts, load_dialog_texts
|
||||
from app.llm.gateway import LLMGateway, get_gateway
|
||||
from app.services import questionnaire as intake_service
|
||||
from app.services.contracts import MonitoringService, get_monitoring
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
|
||||
MAX_ENTER_CHAIN = 8
|
||||
_patient_locks = WeakValueDictionary()
|
||||
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
|
||||
_DB_DOWN = (OSError, OperationalError, InterfaceError)
|
||||
|
||||
@@ -76,8 +81,13 @@ class Engine:
|
||||
async def handle(self, token: str, event: Event) -> list[Reply]:
|
||||
texts = load_dialog_texts()
|
||||
try:
|
||||
lock = _patient_locks.setdefault(token, asyncio.Lock())
|
||||
async with lock:
|
||||
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:
|
||||
log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__)
|
||||
return _degraded(event, texts)
|
||||
@@ -117,6 +127,13 @@ class Engine:
|
||||
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
|
||||
)
|
||||
await self._save_state(ctx)
|
||||
task_id = event.params.get("questionnaire_task")
|
||||
if event.type == "system" and task_id and run.replies:
|
||||
from app.db.intake import QuestionnaireTask
|
||||
|
||||
task = await session.get(QuestionnaireTask, task_id)
|
||||
if task and task.token == token:
|
||||
task.delivery = [r.model_dump() for r in run.replies]
|
||||
log.info(
|
||||
"dialog token=%s event=%s code=%s state=%s->%s replies=%d",
|
||||
token,
|
||||
@@ -143,6 +160,12 @@ class Engine:
|
||||
if event.type == "button":
|
||||
if consented and event.button == "consent:revoke":
|
||||
return await revoke(ctx)
|
||||
if consented and event.button == "intake:help":
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.dialog.flows.intake import urgent
|
||||
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
return await urgent(ctx, row)
|
||||
if consented:
|
||||
await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client
|
||||
return await self._dispatch(ctx, event)
|
||||
@@ -153,6 +176,14 @@ class Engine:
|
||||
if not consented:
|
||||
return Step() # nothing is pushed to people without consent
|
||||
if event.system == "scale_due" and event.params.get("scale"):
|
||||
if intake_enabled() and ctx.data.get("adult") is True:
|
||||
scale = str(event.params["scale"])
|
||||
kind = "cssrs_since_contact" if scale in ("cssrs_screen", "cssrs") else scale
|
||||
if kind in ("phq9", "pcl5", "rus_audit_s", "cssrs_since_contact"):
|
||||
await intake_service.queue_task(
|
||||
ctx.session, ctx.token, kind, f"external:{kind}:{ctx.now.date()}", ctx.now
|
||||
)
|
||||
return Step()
|
||||
scheduled = bool(event.params.get("scheduled", True))
|
||||
pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled)
|
||||
return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step
|
||||
@@ -181,11 +212,29 @@ class Engine:
|
||||
if result.level == RiskLevel.ACUTE:
|
||||
handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script"
|
||||
log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
|
||||
if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True):
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.dialog.flows.intake import urgent
|
||||
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
step = await urgent(ctx, row, code="safety_acute", categories=result.categories)
|
||||
else:
|
||||
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
|
||||
step.data["safety_events"] = self._safety_events(ctx, result, handled)
|
||||
step.notify_monitoring = True # lane B escalates to the doctor on call
|
||||
return step
|
||||
|
||||
if result.level == RiskLevel.POSSIBLE and (
|
||||
ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True)
|
||||
):
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.dialog.flows.intake import manual_review
|
||||
|
||||
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
|
||||
if row is None:
|
||||
row = await intake_service.create_record(ctx, "cssrs_since_contact")
|
||||
return await manual_review(ctx, row, "cssrs_since_contact_required")
|
||||
|
||||
if result.level == RiskLevel.POSSIBLE:
|
||||
screen = ctx.texts.settings.risk_screen_scale
|
||||
if self._can_screen(ctx, screen):
|
||||
@@ -252,6 +301,8 @@ class Engine:
|
||||
|
||||
async def _start_pending(self, ctx: Ctx) -> Step:
|
||||
"""Start a queued scale when nothing else is being asked. In crisis only the risk screen may run."""
|
||||
if intake_enabled():
|
||||
return Step()
|
||||
pending = list(ctx.data.get("pending_scales") or [])
|
||||
if not pending or ctx.state not in RESTING_STATES:
|
||||
return Step()
|
||||
|
||||
@@ -42,6 +42,20 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
||||
[Button(label=b.contact_doctor, data="menu:contact_doctor")],
|
||||
[Button(label=b.revoke, data="consent:revoke")],
|
||||
]
|
||||
from app.core.intake_questions import texts
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
if intake_enabled():
|
||||
buttons.insert(0, [Button(label=texts()["help"], data="intake:help")])
|
||||
buttons.insert(
|
||||
0,
|
||||
[
|
||||
Button(
|
||||
label=texts()["resume"] if ctx.data.get("questionnaire_record") else texts()["start"],
|
||||
data="intake:resume" if ctx.data.get("questionnaire_record") else "intake:start",
|
||||
)
|
||||
],
|
||||
)
|
||||
return Reply(text=text or ctx.texts.idle.menu_prompt, buttons=buttons)
|
||||
|
||||
|
||||
@@ -53,6 +67,10 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
|
||||
profile = await ctx.profile()
|
||||
modules = list(profile.modules) if profile is not None and profile.modules else []
|
||||
from app.core.intake_runtime import intake_enabled
|
||||
|
||||
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
|
||||
return "intake.start"
|
||||
if after in modules:
|
||||
modules = modules[modules.index(after) + 1 :]
|
||||
for module in modules:
|
||||
@@ -275,6 +293,8 @@ async def _reach_out(ctx: Ctx, event: Event) -> Step:
|
||||
|
||||
def close_crisis(ctx: Ctx) -> Step:
|
||||
"""Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards."""
|
||||
if not (ctx.data.get("crisis") or {}).get("open") and ctx.state != "crisis":
|
||||
return Step()
|
||||
crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()}
|
||||
scales = {
|
||||
sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s
|
||||
@@ -311,7 +331,11 @@ async def revoke(ctx: Ctx) -> Step:
|
||||
"""
|
||||
async with get_identity_session() as s:
|
||||
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
|
||||
from app.services.questionnaire import cancel_pending
|
||||
|
||||
await cancel_pending(ctx.session, ctx.token)
|
||||
data = {
|
||||
"questionnaire_record": None,
|
||||
"consent": False,
|
||||
"revoked_at": ctx.now.isoformat(),
|
||||
"delete_requested": True,
|
||||
|
||||
@@ -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
@@ -1,13 +1,176 @@
|
||||
"""Scheduler on the (virtual) demo clock — stub until #13. Called from app.main lifespan."""
|
||||
"""Questionnaire jobs on the demo clock; clinical release disabled by default.
|
||||
|
||||
One scheduler per database. PostgreSQL's advisory transaction lock serializes
|
||||
workers; an asyncio lock does the same within a SQLite demonstration process.
|
||||
Deliveries are at least once: the channel protocol offers no idempotency key.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.core import clock
|
||||
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
|
||||
from app.db import repo
|
||||
from app.db.clinical import PatientProfile
|
||||
from app.db.intake import QuestionnaireTask
|
||||
from app.db.session import get_clinical_session
|
||||
from app.dialog.contracts import Event, Reply, get_engine
|
||||
from app.integrations.base import integrations
|
||||
from app.services import questionnaire as service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_worker = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def start() -> None:
|
||||
"""Start periodic ticking (#13)."""
|
||||
async def plan():
|
||||
now = clock.now()
|
||||
p = load_runtime()
|
||||
local = now.astimezone(ZoneInfo(p.timezone))
|
||||
async with get_clinical_session() as session:
|
||||
profiles = (await session.scalars(select(PatientProfile))).all()
|
||||
for profile in profiles:
|
||||
state = await repo.get_dialog_state(session, profile.token)
|
||||
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
|
||||
continue
|
||||
rows = await service.records(session, profile.token)
|
||||
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
|
||||
if initial is None:
|
||||
continue
|
||||
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
|
||||
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
|
||||
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
|
||||
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
|
||||
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
|
||||
continue
|
||||
if local.hour >= p.daily_hour and not any(
|
||||
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
|
||||
):
|
||||
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
|
||||
last = {}
|
||||
for row in rows:
|
||||
if row.status != "complete":
|
||||
continue
|
||||
for sid in ("phq9", "pcl5", "rus_audit_s"):
|
||||
if row.details.get("totals", {}).get(sid, {}).get("complete"):
|
||||
last[sid] = row.completed_at.astimezone(ZoneInfo(p.timezone)).date()
|
||||
route = initial.details["decision"]["primary_route"]
|
||||
prescriptions = await integrations.prescriptions.get_prescriptions(profile.token)
|
||||
changes = [
|
||||
v.start_date
|
||||
for v in prescriptions
|
||||
if v.start_date and v.start_date <= local.date() and (v.end_date is None or v.end_date >= local.date())
|
||||
]
|
||||
if profile.therapy_start and profile.therapy_start <= local.date():
|
||||
changes.append(profile.therapy_start)
|
||||
closed = (state.data.get("crisis") or {}).get("closed_at")
|
||||
crisis_date = datetime.fromisoformat(closed).astimezone(ZoneInfo(p.timezone)).date() if closed else None
|
||||
scales = due_scales(
|
||||
today=local.date(),
|
||||
enrolled=initial.day,
|
||||
last=last,
|
||||
route="self_help" if route == "self_help" else "specialist",
|
||||
medication_changed=max(changes) if changes else None,
|
||||
after_crisis=crisis_date,
|
||||
policy=p,
|
||||
)
|
||||
if scales or local.hour >= p.daily_hour:
|
||||
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
|
||||
for sid in scales:
|
||||
if sid == "pcl5" and initial.answers.get("trauma") != 1:
|
||||
continue
|
||||
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
|
||||
|
||||
|
||||
async def stop() -> None:
|
||||
"""Stop the scheduler (#13)."""
|
||||
async def deliver():
|
||||
async with get_clinical_session() as session:
|
||||
task_ids = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(QuestionnaireTask.id)
|
||||
.where(
|
||||
QuestionnaireTask.sent_at.is_(None),
|
||||
QuestionnaireTask.status != "cancelled",
|
||||
QuestionnaireTask.due_at <= clock.now(),
|
||||
)
|
||||
.order_by((QuestionnaireTask.kind != "cssrs_since_contact"), QuestionnaireTask.id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for task_id in task_ids:
|
||||
async with get_clinical_session() as session:
|
||||
task = await session.get(QuestionnaireTask, task_id)
|
||||
state = await repo.get_dialog_state(session, task.token)
|
||||
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
|
||||
task.status, task.delivery = "cancelled", []
|
||||
continue
|
||||
if (state.data.get("crisis") or {}).get("open"):
|
||||
continue
|
||||
if task.status == "done" and not task.delivery:
|
||||
continue
|
||||
token = task.token
|
||||
payload = task.delivery
|
||||
if not payload and state.state not in ("idle", "intake.collect"):
|
||||
continue
|
||||
if not payload:
|
||||
await get_engine().handle(
|
||||
token, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
|
||||
)
|
||||
# Serialize delivery with withdrawal of consent and user answers.
|
||||
async with get_clinical_session() as session:
|
||||
await service.lock_patient(session, token)
|
||||
state = await repo.get_dialog_state(session, token)
|
||||
task = await session.get(QuestionnaireTask, task_id)
|
||||
if not state or state.data.get("consent") is not True or not task.delivery:
|
||||
continue
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
integrations.outbox.push(token, [Reply.model_validate(r) for r in task.delivery]), timeout=10
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("questionnaire outbox error=%s", type(exc).__name__)
|
||||
else:
|
||||
task.sent_at = clock.now()
|
||||
|
||||
|
||||
async def run_until_now() -> None:
|
||||
"""Replay all due events up to `app.core.clock.now()` in order, idempotently (#13)."""
|
||||
async def run_until_now():
|
||||
async with _lock, get_clinical_session() as guard:
|
||||
if guard.bind.dialect.name == "postgresql" and not await guard.scalar(
|
||||
text("SELECT pg_try_advisory_xact_lock(520053)")
|
||||
):
|
||||
return
|
||||
await service.dispatch_notices()
|
||||
if not intake_enabled():
|
||||
return
|
||||
await plan()
|
||||
await deliver()
|
||||
await service.dispatch_notices()
|
||||
|
||||
|
||||
async def _run():
|
||||
while True:
|
||||
try:
|
||||
await run_until_now()
|
||||
except Exception as exc:
|
||||
log.error("questionnaire scheduler error=%s", type(exc).__name__)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
async def start():
|
||||
global _worker
|
||||
if _worker is None and intake_enabled():
|
||||
_worker = asyncio.create_task(_run())
|
||||
|
||||
|
||||
async def stop():
|
||||
global _worker
|
||||
if _worker is not None:
|
||||
_worker.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await _worker
|
||||
_worker = None
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,61 @@
|
||||
# Verbatim source content; only whitespace/line wraps normalized. Not enabled in the legacy dialog.
|
||||
id: cssrs_recent_ru
|
||||
version: source-2026-10-03
|
||||
title: КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА
|
||||
subtitle: Screen Version - Recent
|
||||
period_label_original: Последние 1 месяца
|
||||
initial_instruction: Задайте вопросы 1 и 2
|
||||
branch_instruction: ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6.
|
||||
options:
|
||||
- value: 1
|
||||
label: Да
|
||||
- value: 0
|
||||
label: Нет
|
||||
items:
|
||||
- id: q1
|
||||
text: Возникало ли у Вас желание умереть или заснуть и не просыпаться?
|
||||
interviewer_guidance: null
|
||||
recall_period: past_month
|
||||
yes_risk: low
|
||||
- id: q2
|
||||
text: Думали ли Вы в самом деле о том, чтобы убить себя?
|
||||
interviewer_guidance: null
|
||||
recall_period: past_month
|
||||
yes_risk: low
|
||||
- id: q3
|
||||
text: Думали ли Вы о том, как Вы могли бы это сделать?
|
||||
interviewer_guidance: 'Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения
|
||||
он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал
|
||||
конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства,
|
||||
но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так
|
||||
и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело
|
||||
до конца”.'
|
||||
recall_period: past_month
|
||||
yes_risk: moderate
|
||||
- id: q4
|
||||
text: Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними?
|
||||
interviewer_guidance: 'Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать
|
||||
в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно
|
||||
не стану ничего предпринимать”.'
|
||||
recall_period: past_month
|
||||
yes_risk: high
|
||||
- id: q5
|
||||
text: Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить
|
||||
этот план?
|
||||
interviewer_guidance: null
|
||||
recall_period: past_month
|
||||
yes_risk: high
|
||||
- id: q6_lifetime
|
||||
text: Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить
|
||||
с собой?
|
||||
interviewer_guidance: 'Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание
|
||||
или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять
|
||||
или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались
|
||||
застрелиться, порезались, пытался повеситься и т. д.'
|
||||
recall_period: lifetime
|
||||
yes_risk: moderate
|
||||
- id: q6_recent
|
||||
text: ЕСЛИ ДА, то было ли это в последние 3 месяца?
|
||||
interviewer_guidance: null
|
||||
recall_period: past_3_months
|
||||
yes_risk: high
|
||||
@@ -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
|
||||
@@ -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: Случалось ли с Вами подобное событие?
|
||||
@@ -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
|
||||
@@ -0,0 +1,13 @@
|
||||
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
|
||||
version: mvp-v2-review-2026-10-03
|
||||
approval: null
|
||||
cssrs_positive_policy: pending
|
||||
rus_audit_s_expand_from_zone: null
|
||||
phq2_expand_at: 2
|
||||
tsq_expand_at: 6
|
||||
phq9_psychologist_at: 5
|
||||
phq9_specialist_at: 10
|
||||
phq9_priority_at: 20
|
||||
pcl5_specialist_at: 31
|
||||
pcl5_priority_at: 50
|
||||
pcl5_behavior_flag_at: 3
|
||||
@@ -0,0 +1,58 @@
|
||||
id: rus_audit
|
||||
title: RUS-AUDIT
|
||||
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
|
||||
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
|
||||
translation_note: "Текст из предоставленного PDF «Опросники для MVP», с.7–9. Самозаполнение полной интервью-формы не утверждено."
|
||||
notes: "Пункт 2 требует таблицы стандартных порций из исходного бланка. Не заменять произвольным калькулятором. Автопереходы не утверждены."
|
||||
options:
|
||||
- {value: 0, label: "Никогда"}
|
||||
- {value: 1, label: "Реже одного раза в месяц"}
|
||||
- {value: 2, label: "Ежемесячно"}
|
||||
- {value: 3, label: "Еженедельно"}
|
||||
- {value: 4, label: "Ежедневно или почти ежедневно"}
|
||||
items:
|
||||
- id: rus_audit_1
|
||||
text: "Как часто Вы употребляете алкогольные напитки?"
|
||||
options:
|
||||
- {value: 0, label: "Никогда"}
|
||||
- {value: 1, label: "Раз в месяц или реже"}
|
||||
- {value: 2, label: "2–4 раза в месяц"}
|
||||
- {value: 3, label: "2–3 раза в неделю"}
|
||||
- {value: 4, label: "4 раза в неделю и чаще"}
|
||||
- id: rus_audit_2
|
||||
text: "Сколько алкогольных напитков (стандартных порций) Вы употребляете в типичный день, когда выпиваете? Если в типичный день Вы употребляете несколько разных алкогольных напитков, то суммируйте число СП."
|
||||
options:
|
||||
- {value: 0, label: "1–2"}
|
||||
- {value: 1, label: "3–4"}
|
||||
- {value: 2, label: "5–6"}
|
||||
- {value: 3, label: "7–9"}
|
||||
- {value: 4, label: "10 и больше"}
|
||||
- {id: rus_audit_3, text: "Как часто Вы употребляете как минимум 1,5 л пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"}
|
||||
- {id: rus_audit_4, text: "Как часто за последние 12 месяцев Вы не могли остановиться, начав употреблять алкогольные напитки?"}
|
||||
- {id: rus_audit_5, text: "Как часто за последние 12 месяцев из-за выпивки Вы не сделали то, что от Вас обычно ожидалось?"}
|
||||
- {id: rus_audit_6, text: "Как часто за последние 12 месяцев Вам необходимо было выпить утром, чтобы прийти в себя после выпивки (опохмелиться)?"}
|
||||
- {id: rus_audit_7, text: "Как часто за последние 12 месяцев Вы испытывали чувство вины или сожаления после выпивки?"}
|
||||
- {id: rus_audit_8, text: "Как часто за последние 12 месяцев Вы были неспособны вспомнить, что было накануне, из-за того, что Вы выпивали?"}
|
||||
- id: rus_audit_9
|
||||
text: "Являлось ли Ваше употребление алкогольных напитков причиной травмы у Вас или у других людей?"
|
||||
options: &history
|
||||
- {value: 0, label: "Никогда"}
|
||||
- {value: 2, label: "Да, более 12 месяцев назад"}
|
||||
- {value: 4, label: "Да, в течение последних 12 месяцев"}
|
||||
- id: rus_audit_10
|
||||
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчёт употребления Вами алкоголя или советовал выпивать меньше?"
|
||||
options: *history
|
||||
range: {min: 0, max: 40}
|
||||
severity:
|
||||
- {min: 0, max: 40, code: unclassified, label: "Не выбран пол для интерпретации"}
|
||||
by_sex:
|
||||
f:
|
||||
- {min: 0, max: 4, code: zone_1, label: "Зона I"}
|
||||
- {min: 5, max: 9, code: zone_2, label: "Зона II"}
|
||||
- {min: 10, max: 10, code: zone_3, label: "Зона III"}
|
||||
- {min: 11, max: 40, code: zone_4, label: "Зона IV"}
|
||||
m:
|
||||
- {min: 0, max: 8, code: zone_1, label: "Зона I"}
|
||||
- {min: 9, max: 13, code: zone_2, label: "Зона II"}
|
||||
- {min: 14, max: 16, code: zone_3, label: "Зона III"}
|
||||
- {min: 17, max: 40, code: zone_4, label: "Зона IV"}
|
||||
@@ -0,0 +1,41 @@
|
||||
id: rus_audit_s
|
||||
title: RUS-AUDIT-S
|
||||
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
|
||||
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
|
||||
translation_note: "Транскрипция рис.3 Буновой и соавт., 2022, DOI 10.17116/profmed20222508121; визуально сверена 2026-10-03. Интеграция не утверждена."
|
||||
notes: "Пункты 3,9,10 полной шкалы. Зоны не задают автоматический переход к полной шкале. Не применять пороги AUDIT-C."
|
||||
items:
|
||||
- id: rus_audit_3
|
||||
text: "Как часто Вы употребляете как минимум 1,5 литра пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"
|
||||
options:
|
||||
- {value: 0, label: "Никогда"}
|
||||
- {value: 1, label: "Реже одного раза в месяц"}
|
||||
- {value: 2, label: "Ежемесячно"}
|
||||
- {value: 3, label: "Еженедельно"}
|
||||
- {value: 4, label: "Ежедневно или почти ежедневно"}
|
||||
- id: rus_audit_9
|
||||
text: |-
|
||||
Являлось ли Ваше употребление алкогольных напитков причиной травмы* у Вас или у других людей?
|
||||
|
||||
*Повреждение организма, вызванное внешним воздействием (ранением, ушибом, ожогом и т.д.).
|
||||
options: &history
|
||||
- {value: 0, label: "Никогда"}
|
||||
- {value: 2, label: "Да, более 12 месяцев назад"}
|
||||
- {value: 4, label: "Да, в течение последних 12 месяцев"}
|
||||
- id: rus_audit_10
|
||||
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчет употребления Вами алкоголя или советовал выпивать меньше?"
|
||||
options: *history
|
||||
range: {min: 0, max: 12}
|
||||
severity:
|
||||
- {min: 0, max: 12, code: unclassified, label: "Не выбран пол для интерпретации"}
|
||||
by_sex:
|
||||
f:
|
||||
- {min: 0, max: 1, code: zone_1, label: "Зона I"}
|
||||
- {min: 2, max: 4, code: zone_2, label: "Зона II"}
|
||||
- {min: 5, max: 5, code: zone_3, label: "Зона III"}
|
||||
- {min: 6, max: 12, code: zone_4, label: "Зона IV"}
|
||||
m:
|
||||
- {min: 0, max: 3, code: zone_1, label: "Зона I"}
|
||||
- {min: 4, max: 6, code: zone_2, label: "Зона II"}
|
||||
- {min: 7, max: 7, code: zone_3, label: "Зона III"}
|
||||
- {min: 8, max: 12, code: zone_4, label: "Зона IV"}
|
||||
@@ -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 года; условия использования и актуальную редакцию подтвердить до включения."
|
||||
@@ -14,9 +14,9 @@ resources:
|
||||
phone: "112"
|
||||
note: "если есть угроза жизни прямо сейчас"
|
||||
helpline:
|
||||
name: "Телефон доверия"
|
||||
phone: "8-800-2000-122"
|
||||
note: "бесплатно, круглосуточно"
|
||||
name: "Психологическая помощь МЧС России"
|
||||
phone: "+7 (495) 989-50-50"
|
||||
note: "круглосуточно; стоимость звонка по тарифу оператора"
|
||||
police:
|
||||
name: "Полиция"
|
||||
phone: "102"
|
||||
@@ -30,7 +30,7 @@ variants:
|
||||
себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку
|
||||
и побудьте рядом с кем-то, кому доверяете.
|
||||
resources: [emergency, helpline]
|
||||
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом."
|
||||
|
||||
patient.harm_others:
|
||||
@@ -39,7 +39,7 @@ variants:
|
||||
Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее.
|
||||
Если кому-то угрожает опасность прямо сейчас — позвоните 112.
|
||||
resources: [emergency, helpline]
|
||||
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз."
|
||||
|
||||
patient.domestic_violence:
|
||||
@@ -48,7 +48,7 @@ variants:
|
||||
Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
|
||||
Кризисные службы и телефон доверия помогут найти безопасное место и поддержку.
|
||||
resources: [emergency, police, helpline]
|
||||
doctor_notified: "Ваш врач уже уведомлён."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом."
|
||||
|
||||
family.default:
|
||||
@@ -58,7 +58,7 @@ variants:
|
||||
спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите —
|
||||
просто будьте рядом и слушайте.
|
||||
resources: [emergency, helpline]
|
||||
doctor_notified: "Лечащий врач уже уведомлён."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6."
|
||||
|
||||
family.domestic_violence:
|
||||
@@ -68,5 +68,5 @@ variants:
|
||||
Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
|
||||
Кризисные службы помогут с временным убежищем и поддержкой.
|
||||
resources: [emergency, police, helpline]
|
||||
doctor_notified: "Лечащий врач уже уведомлён."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6."
|
||||
|
||||
@@ -75,9 +75,9 @@ revoked:
|
||||
|
||||
crisis:
|
||||
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
|
||||
repeat: "Я рядом. Ваш врач уже уведомлён. Если вам угрожает опасность прямо сейчас — позвоните:"
|
||||
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
|
||||
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
|
||||
hold: "Я на связи и читаю вас. Ваш врач в курсе и свяжется с вами. Если станет хуже — звоните 112 или на телефон доверия 8-800-2000-122."
|
||||
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
|
||||
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
|
||||
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
|
||||
|
||||
@@ -97,7 +97,7 @@ safety_plan:
|
||||
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
|
||||
- "Люди и места, которые отвлекают: к кому можно пойти."
|
||||
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
|
||||
- "Специалисты: ваш врач, телефон доверия 8-800-2000-122, экстренные службы 112."
|
||||
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
|
||||
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
|
||||
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
|
||||
|
||||
@@ -111,4 +111,4 @@ visit:
|
||||
|
||||
unavailable: |-
|
||||
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
|
||||
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия 8-800-2000-122.
|
||||
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
|
||||
|
||||
@@ -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: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план."
|
||||
@@ -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 не выполняет ни один из этих пунктов.
|
||||
Binary file not shown.
@@ -0,0 +1,203 @@
|
||||
# Опросник: пакет проверки от 3 октября 2026
|
||||
|
||||
Статус: **подготовлено для проверки, не утверждено для пациентов**. Задача #52;
|
||||
клиническое согласование остаётся в #8. Независимая проверка обратного перевода, согласование
|
||||
врачом и проверка линий звонками не выполнены. В `review-status.json` все эти
|
||||
статусы оставлены незавершёнными.
|
||||
|
||||
## Визуальный отчёт по PR #53
|
||||
|
||||
[Скачать PDF: проделанная работа и архитектура](PR53-implementation-report.pdf).
|
||||
Восемь страниц: состав изменений, архитектура, опросы, расписания, хранение данных,
|
||||
проверки и открытые вопросы. Срез коммита `c42ef62`; в отчёте отдельно отмечено
|
||||
расхождение расписания для маршрута «психолог» (14/30 вместо 28/28 дней).
|
||||
|
||||
## Обновление: PDF на 19 страницах
|
||||
|
||||
Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md)
|
||||
и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены
|
||||
отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания.
|
||||
**Новая спецификация подключена к диалогу за закрытым переключателем.**
|
||||
[Состав реализации, ограничения и порядок проверки](IMPLEMENTATION.md).
|
||||
Текст TSQ получен отдельно из приложения Г1 клинических рекомендаций 2023 года;
|
||||
актуальность этой редакции ещё нужно подтвердить.
|
||||
Независимая проверка перевода остаётся pending.
|
||||
Описания реализованных правил и ссылки на страницы ниже относятся к исходному
|
||||
14-страничному PDF, если явно не указана новая версия.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
`app/core/questionnaire.py` содержит детерминированные правила первичного опроса:
|
||||
короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5,
|
||||
условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent,
|
||||
приоритет основного маршрута и параллельные направления. Правила подключены к версионированному диалогу, дневнику и планировщику.
|
||||
Замороженные контракты, `config/modules.yaml` и исторические результаты не менялись.
|
||||
Клиническое включение заблокировано до подписей.
|
||||
|
||||
`config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без
|
||||
записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий
|
||||
сигнал C-SSRS и ручной запрос помощи дают `crisis` независимо от этого ограничения.
|
||||
`clinical_review_required` при других положительных C-SSRS означает остановку
|
||||
обычного потока и необходимость человеческой проверки, а не присвоение высокого
|
||||
риска. Для неполного C-SSRS возвращается нижняя граница риска, итоговый риск пуст.
|
||||
|
||||
Использовать в адаптере `score_alcohol`, а не низкоуровневый `scales.score` для
|
||||
маршрутизации: первый не выдаёт итоговую сумму/зону неполной анкеты. Известный пол
|
||||
нужен только для интерпретации алкогольной шкалы; неизвестный не заменяется мужским
|
||||
или женским. Нет ответа, отказ и технически пропущенный вопрос не равны нулю.
|
||||
|
||||
## RUS-AUDIT-S: исправление спецификации
|
||||
|
||||
В PDF на с.7–9 первые три пункта ошибочно названы RUS-AUDIT-S. Валидированная
|
||||
краткая форма состоит из **пунктов 3, 9, 10** полного RUS-AUDIT, в этом порядке.
|
||||
Пункт 3 имеет значения 0/1/2/3/4, пункты 9 и 10 — только 0/2/4; сумма 0–12.
|
||||
|
||||
| Зона | Женщины | Мужчины |
|
||||
|---|---|---|
|
||||
| I | 0–1 | 0–3 |
|
||||
| II | 2–4 | 4–6 |
|
||||
| III | 5 | 7 |
|
||||
| IV | 6–12 | 8–12 |
|
||||
|
||||
Источник состава: [Neufeld et al., Bulletin of WHO, 2021](https://pmc.ncbi.nlm.nih.gov/articles/PMC8243036/).
|
||||
Зоны и текст для самозаполнения: [Бунова и соавт., 2022, рис.3](https://www.mediasphera.ru/issues/profilakticheskaya-meditsina/2022/8/1230549482022081021),
|
||||
DOI 10.17116/profmed20222508121. Рисунок визуально проверен, включая пояснение
|
||||
слова «травма» во втором вопросе. В интерфейсе это пояснение нельзя опускать.
|
||||
|
||||
При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10
|
||||
переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт
|
||||
получения ответа в краткой форме: её пояснение травмы отличается от полного текста.
|
||||
Правило PDF `п.1 = 0` в новой версии выводит ноль только для 2; исходный 3 сохраняется, как и 9/10;
|
||||
выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется.
|
||||
|
||||
Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является
|
||||
материалом для согласования, не независимо подтверждённым русским бланком.
|
||||
Для пункта 2 адаптеру нужна исходная таблица стандартных порций (с.8), а не
|
||||
произвольное округление смешанных напитков. Применение полной интервью-формы
|
||||
как самоопроса требует отдельного решения врача.
|
||||
|
||||
RUS-AUDIT: © ВОЗ, 2021; [отчёт ВОЗ](https://www.who.int/europe/publications/i/item/9789289055765),
|
||||
CC BY-NC-SA 3.0 IGO. Использование в этом пакете — некоммерческий прототип.
|
||||
Наличие шкалы здесь не означает одобрения продукта ВОЗ или авторами.
|
||||
|
||||
## C-SSRS: русский бланк Recent
|
||||
|
||||
Официальная [страница переводов Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/)
|
||||
ссылается на [Dropbox Screeners](https://www.dropbox.com/scl/fo/pkg1gwxxn1266rke8oite/AC2UFfOKCtefU-o9EEXP-yk?dl=0&rlkey=4grouuqbchfa8obxlcb3msnpz).
|
||||
3 октября пользователь предоставил локальный `C-SSRS Screener - Russian.pdf`.
|
||||
Неизменённая копия — `cssrs-screener-russian.pdf`, одна страница, заголовок
|
||||
**Screen Version - Recent**. Вопросы, пояснения, периоды, варианты и цветовые
|
||||
категории сверены с отрисованной страницей. Хеши оригинала и извлечённого текста
|
||||
сохранены в `review-status.json`. Файл получен от пользователя после блокировки
|
||||
загрузки; совпадение байтов с удалённой копией Dropbox независимо не проверено.
|
||||
|
||||
`config/instruments/cssrs_recent_ru.yaml` содержит отдельную транскрипцию,
|
||||
`app/core/cssrs_content.py` — типизированный загрузчик `load_cssrs_russian`.
|
||||
Версия `source-2026-10-03` — локальная версия содержания, **не номер редакции
|
||||
Columbia**. Даты в метаданных PDF не используются как дата клинической редакции.
|
||||
Сохранены все формулировки и опечатки, включая «Последние 1 месяца»,
|
||||
«ЕСЛИ ДА 2», «пытался повеситься» и повтор «запаслись таблетками».
|
||||
Нормализованы только межсловные пробелы и переносы. Пояснения интервьюеру
|
||||
хранятся отдельно от вопросов. Любое редактирование требует отдельного решения.
|
||||
|
||||
Переходы по бланку: q1/q2 всем; при q2=0 перейти к q6; при q2=1 спросить q3–q5;
|
||||
q6 за всю жизнь всем; при положительном q6 уточнить последние три месяца.
|
||||
Для q1–q5 период — последний месяц. Ответы «Да»/«Нет» кодируются 1/0;
|
||||
это коды ответов, **не суммируемые баллы**.
|
||||
|
||||
| Положительный ответ | Цвет бланка | Категория |
|
||||
|---|---|---|
|
||||
| q1, q2 | жёлтый | low |
|
||||
| q3, q6_lifetime | оранжевый | moderate |
|
||||
| q4, q5, q6_recent | красный | high |
|
||||
|
||||
При нескольких положительных ответах используется максимальная категория.
|
||||
Поведение ранее трёх месяцев не превращается в «риска нет».
|
||||
Правила совпадают с [Recent Screener для первичной помощи](https://cssrs.columbia.edu/wp-content/uploads/C-SSRS-Screener-with-Triage-Points-for-Primary-Care-2026.pdf)
|
||||
и [описанием триажа Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/risk-identification/).
|
||||
Неполные или противоречивые данные не получают итоговую категорию;
|
||||
известный сигнал сохраняется как нижняя граница риска.
|
||||
|
||||
`config/scales/cssrs_screen.yaml` остаётся историческим демо-переводом. Его q6
|
||||
спрашивает только о последних трёх месяцах; автоматически конвертировать такой
|
||||
ответ в пожизненный нельзя. Новый бланк расположен вне `config/scales`, поскольку
|
||||
действующий последовательный диалог не поддерживает его условные переходы.
|
||||
Он ещё не включён в пациентский сценарий. Загрузчик текста сам по себе не
|
||||
разрешает продолжение опроса: адаптер должен учитывать `evaluate_intake`,
|
||||
а порядок обязательных вопросов получать из `score_cssrs_recent`.
|
||||
|
||||
Бланк не разрешает противоречие об остановке при любом «Да»: C01 остаётся
|
||||
решением врача. Также нужны C06 (возраст/популяция, формат применения, обучение
|
||||
и повторные замеры) и согласование пациентского сценария. Версию «с последнего
|
||||
контакта» нельзя получить простой заменой заголовка Recent. Статус лицензии и
|
||||
применимость к коммерческому продукту подтверждаются отдельно.
|
||||
|
||||
## Обратный перевод и независимая проверка
|
||||
|
||||
Для новой версии подготовлено 32 единицы: 28 PCL-5 и 4 из сохранённого блока
|
||||
травмы. Русский текст сверён с PDF с.3 и 5–7. Перевод подготовлен как рабочий
|
||||
вариант и требует независимой проверки. Результат и последующая сверка
|
||||
с оригиналом разделены в `revision-2026-10-03-v2/`.
|
||||
|
||||
Независимому человеку передать **только**
|
||||
`revision-2026-10-03-v2/backtranslation-blind.tsv`:
|
||||
|
||||
1. Переводчик со свободным английским не должен быть автором исходного русского
|
||||
текста. Перевести все 32 единицы, не читая английский оригинал и подготовленный рабочий перевод.
|
||||
2. Сохранить ID и порядок. Заполнить перевод, сомнения, имя и дату; русский текст
|
||||
не исправлять. Если оригинал уже знаком, явно указать это ограничение.
|
||||
3. Координатор и врач затем заполняют согласованный русский текст, решение,
|
||||
подпись и дату в `revision-2026-10-03-v2/translation-reconciliation.tsv`.
|
||||
Предварительные замечания в нём являются предложениями для проверки.
|
||||
|
||||
Исходные файлы `backtranslation-blind.tsv` и `translation-reconciliation.tsv`
|
||||
в этой папке сохраняются как пакет прежней версии на 38 единиц. Пять симптомных
|
||||
пунктов PC-PTSD-5 и его инструкция больше не входят в актуальный пакет.
|
||||
Обратный перевод сам по себе не доказывает
|
||||
психометрическую валидность. Клинические данные переводчикам не передаются.
|
||||
|
||||
## Решения врача — поля намеренно не заполнены
|
||||
|
||||
| ID | Решение, которое нужно зафиксировать | Статус |
|
||||
|---|---|---|
|
||||
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending |
|
||||
| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
|
||||
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
|
||||
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending |
|
||||
| C05 | Привязка PHQ-9/PCL-5 к специалистам, сроки срочной помощи, параллельные маршруты и состав флагов. | pending |
|
||||
| C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending |
|
||||
| C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending |
|
||||
| C08 | Повторы, ухудшение, разные периоды оценки и выход из кризисного режима; расписание после смены лекарств 7/14/28 дней и далее 4 недели. | pending |
|
||||
| C09 | Дневник: якоря ответов, направление показателей, пропуски, окно среднего, ночные флаги, приверженность и сроки реакции. См. разбор новой версии. | pending |
|
||||
|
||||
Форма решения: ID; выбранный вариант; обоснование; ФИО/роль врача; дата с часовым
|
||||
поясом; версия политики; ссылка на подписанный протокол; проверенные сценарии.
|
||||
После решений C01/C02 и утверждения остальных применяемых порогов заполнить
|
||||
`approval` в конфиге и изменить версию. Это запись о реальном согласовании, а не
|
||||
переключатель для снятия блокировки. Синтетические записи в тестах не являются
|
||||
подписью врача.
|
||||
|
||||
## Подключение к диалогу
|
||||
|
||||
Адаптер проверяет непосредственные сигналы после каждого ответа и на кнопке помощи;
|
||||
при завершении вызывает `evaluate_intake` для общего маршрута.
|
||||
`crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required`
|
||||
не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные
|
||||
пропуски; `ready` содержит основной маршрут и отдельные параллельные направления.
|
||||
Кнопка помощи доступна на каждом экране. Не утверждать, что врач уведомлён,
|
||||
пока интеграция не подтвердила доставку. Обычный опрос после кризиса сам не возобновляется.
|
||||
|
||||
Хранить ответы, версию шкалы/политики, период, время из `app.core.clock`, исходные
|
||||
и выведенные значения раздельно, флаги и состояние полноты. Новые ключи C-SSRS
|
||||
не подменяют старый cssrs_6. Нельзя сравнивать динамику разных версий и периодов
|
||||
как одну и ту же шкалу. Клинические данные не отправляются переводчикам.
|
||||
|
||||
## Проверка
|
||||
|
||||
`make lint && make test`. Новые тесты проверяют все 45 допустимых наборов
|
||||
RUS-AUDIT-S для каждого пола, границы полной формы, отрицательную и положительную
|
||||
ветви C-SSRS, давнее поведение, пропуски/противоречия, кризис на неполном опросе,
|
||||
конфликт PHQ-9/9 и C-SSRS, пороги маршрутов, независимые направления и отсутствие
|
||||
разрешения на автоматический переход без реального решения врача.
|
||||
Тесты C-SSRS дополнительно восстанавливают полный текст страницы из структуры,
|
||||
проверяют хеши и сверяют все комбинации ветвей с цветовыми категориями бланка.
|
||||
@@ -0,0 +1,39 @@
|
||||
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
|
||||
"pcptsd5" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" ""
|
||||
"pcptsd5" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" ""
|
||||
"pcptsd5" "instruction" "3" "За последний месяц Вы…" "" "" "" ""
|
||||
"pcptsd5" "pcptsd5_1" "4" "…видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?" "" "" "" ""
|
||||
"pcptsd5" "pcptsd5_2" "4" "…изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?" "" "" "" ""
|
||||
"pcptsd5" "pcptsd5_3" "4" "…были постоянно настороже, начеку или легко вздрагивали?" "" "" "" ""
|
||||
"pcptsd5" "pcptsd5_4" "4" "…чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?" "" "" "" ""
|
||||
"pcptsd5" "pcptsd5_5" "4" "…чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?" "" "" "" ""
|
||||
"pcptsd5" "option_Да" "3–4" "Да" "" "" "" ""
|
||||
"pcptsd5" "option_Нет" "3–4" "Нет" "" "" "" ""
|
||||
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" ""
|
||||
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" ""
|
||||
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" ""
|
||||
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" ""
|
||||
"pcl5" "pcl5_2" "5" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" ""
|
||||
"pcl5" "pcl5_3" "5" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" ""
|
||||
"pcl5" "pcl5_4" "5" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" ""
|
||||
"pcl5" "pcl5_5" "5" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" ""
|
||||
"pcl5" "pcl5_6" "5" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" ""
|
||||
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" ""
|
||||
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" ""
|
||||
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" ""
|
||||
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" ""
|
||||
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" ""
|
||||
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" ""
|
||||
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" ""
|
||||
"pcl5" "pcl5_14" "6" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" ""
|
||||
"pcl5" "pcl5_15" "6" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" ""
|
||||
"pcl5" "pcl5_16" "6" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" ""
|
||||
"pcl5" "pcl5_17" "6" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" ""
|
||||
"pcl5" "pcl5_18" "6" "Взвинченность или склонность легко вздрагивать?" "" "" "" ""
|
||||
"pcl5" "pcl5_19" "6" "Трудности с концентрацией внимания?" "" "" "" ""
|
||||
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" ""
|
||||
"pcl5" "option_0" "5" "Совсем нет" "" "" "" ""
|
||||
"pcl5" "option_1" "5" "Немного" "" "" "" ""
|
||||
"pcl5" "option_2" "5" "Умеренно" "" "" "" ""
|
||||
"pcl5" "option_3" "5" "Сильно" "" "" "" ""
|
||||
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" ""
|
||||
|
@@ -0,0 +1,43 @@
|
||||
# Проверка контактов: 3 октября 2026
|
||||
|
||||
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
|
||||
или уведомлений службам/врачу не выполнялось.
|
||||
|
||||
| Служба | Номер | Область применения | Что подтверждено сайтом |
|
||||
|---|---|---|---|
|
||||
| Экстренные службы | 112 | Россия, непосредственная угроза жизни | Круглосуточно и бесплатно: [МЧС](https://76.mchs.gov.ru/deyatelnost/poleznaya-informaciya/rekomendacii-naseleniyu/sistema-112) |
|
||||
| Психологическая помощь МЧС | +7 (495) 989-50-50 | Психологическая помощь; московский городской номер | Номер опубликован [самой службой](https://psi.mchs.gov.ru/). Бесплатность звонка и доступность из конкретного региона не подтверждались. |
|
||||
| Московская служба психологической помощи | 051 с городского; 8 (495) 051 с мобильного | Москва, региональный ресурс | [Страница службы](https://msph.ru/uslugi/neotlozhnaya-psikhologicheskaya-pomoshch): круглосуточно; городской звонок бесплатный, мобильный тарифицируется оператором. Не преобразовывать необычный короткий номер в выдуманный E.164. |
|
||||
| Детский телефон доверия | 8-800-2000-122 | Дети, подростки и родители по вопросам ребёнка | [Официальная страница](https://telefon-doveria.ru/about/): аудитория, анонимность и бесплатность. Не универсальная взрослая кризисная линия. Часы работы конкретного регионального подразделения нужно уточнить. |
|
||||
|
||||
В `config/texts/crisis.yaml` универсальная взрослая ветка исправлена на линию МЧС;
|
||||
детский номер сохранён в возрастной ветке с указанием аудитории. Безусловное
|
||||
обещание уведомления врача удалено. Формулировки и готовность линий требуют
|
||||
согласования перед включением нового опросника.
|
||||
|
||||
## Дополнения из новой версии PDF
|
||||
|
||||
| Ресурс | Проверка источника | Ограничение |
|
||||
|---|---|---|
|
||||
| МГППУ, 8-800-600-31-14 | [Сообщение МГППУ](https://mgppu.ru/news/10402) публикует номер для детей и родителей | Не включён как универсальная линия для всех взрослых; часы и доступность проверить у службы. |
|
||||
| Санкт-Петербург, 8 (812) 708-40-41 | [Справочник администрации](https://www.gov.spb.ru/gov/admgub/obrasheniya/faq/13551/) указывает телефон Института «Гармония» для детей и взрослых | Региональный ресурс; охват Ленинградской области отдельно не подтверждён. Повторное открытие страницы дало ошибку; перед включением перепроверить. |
|
||||
| Фонд «Защитники Отечества», 117, доб.1 | [Страница государственного медучреждения](https://xn--7--9kcgbq1fn7g.xn--p1ai/информация-для-участников-специальн/) указывает номер фонда | Сайт самого фонда при проверке недоступен. Номер поддержки ветеранов и семей не объявляется круглосуточной экстренной психиатрической линией. В активный кризисный экран не добавлен. |
|
||||
|
||||
Звонки по этим трём позициям также не выполнены.
|
||||
|
||||
Номер 112 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная
|
||||
ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих
|
||||
линий согласовать с оператором службы проверку доступности без имитации кризиса.
|
||||
|
||||
## Журнал операционной проверки (не выполнена)
|
||||
|
||||
| Служба | Проверяющий | Дата/часовой пояс | Регион/оператор связи | Доступность/часы/стоимость | Альтернативный ресурс | Результат |
|
||||
|---|---|---|---|---|---|---|
|
||||
| МЧС | | | | | | pending |
|
||||
| МСППН | | | | | | pending |
|
||||
| Детский телефон доверия | | | | | | pending |
|
||||
|
||||
Отдельно подтвердить наличие живого дежурного в самом продукте и время реакции.
|
||||
Кнопка, мок или очередь уведомлений не являются подтверждённым контактом с человеком.
|
||||
За пределами России приложение должно уточнять страну и давать местные экстренные
|
||||
ресурсы, а не обещать доступность российских коротких номеров.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА Screen Version - Recent Последние 1 месяца Задайте вопросы 1 и 2 Да Нет 1. Возникало ли у Вас желание умереть или заснуть и не просыпаться? 2. Думали ли Вы в самом деле о том, чтобы убить себя? ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6. 3. Думали ли Вы о том, как Вы могли бы это сделать? Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства, но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело до конца”. 4. Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними? Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно не стану ничего предпринимать”. 5. Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить этот план? 6. Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить с собой? Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались застрелиться, порезались, пытался повеситься и т. д. ЕСЛИ ДА, то было ли это в последние 3 месяца? Low Risk Moderate Risk High Risk
|
||||
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"prepared_on": "2026-10-03",
|
||||
"source_pdf_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
|
||||
"source_pdf": "questionnaires-mvp-source.pdf",
|
||||
"translation_status": "draft_completed_independent_review_pending",
|
||||
"clinician_approval": null,
|
||||
"phone_checks": "not_performed",
|
||||
"cssrs_russian_pdf": {
|
||||
"original_filename": "C-SSRS Screener - Russian.pdf",
|
||||
"received": true,
|
||||
"acquisition": "user_provided_local_file",
|
||||
"received_on": "2026-10-03",
|
||||
"file": "cssrs-screener-russian.pdf",
|
||||
"sha256": "d1dc77068b73e83a0e7959681b0fe9f15775a8b99863cf0e11794308a4c77c85",
|
||||
"source_text": "cssrs-screener-russian.source.txt",
|
||||
"source_text_sha256": "89835360bd59c6ead1a56bec6f40c46b72f81165366c92f85ba5d25291748de7",
|
||||
"text_verified": true,
|
||||
"visual_verification": true,
|
||||
"pages": 1,
|
||||
"form_subtitle": "Screen Version - Recent",
|
||||
"content_version": "source-2026-10-03",
|
||||
"publisher_version": null,
|
||||
"source": "https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/",
|
||||
"remote_bytes_independently_verified": false,
|
||||
"clinical_use_approved": false,
|
||||
"note": "User supplied the file after the official-source download workflow was blocked. Local content and colors checked; publisher version is not inferred from PDF metadata."
|
||||
},
|
||||
"intake_release_ready": false,
|
||||
"policy_version": "mvp-v2-review-2026-10-03",
|
||||
"latest_specification": {
|
||||
"source_pdf": "revision-2026-10-03-v2/questionnaires-mvp-v2.pdf",
|
||||
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
|
||||
"pages": 19,
|
||||
"review": "revision-2026-10-03-v2/REVIEW.md",
|
||||
"implemented": true,
|
||||
"pending_changes": [],
|
||||
"tsq_verbatim_source_verified": true,
|
||||
"current_guideline_edition_verified": false,
|
||||
"implementation_scope": "gated_intake_diaries_schedules_manual_followup_risk_review",
|
||||
"implementation": "IMPLEMENTATION.md"
|
||||
},
|
||||
"backtranslation": {
|
||||
"units": 32,
|
||||
"result": "revision-2026-10-03-v2/backtranslation-draft.tsv",
|
||||
"reconciliation": "revision-2026-10-03-v2/translation-reconciliation.tsv",
|
||||
"clinician_approval": null,
|
||||
"independent_review": "not_performed"
|
||||
},
|
||||
"cssrs_since_contact": {
|
||||
"russian_source_received": false,
|
||||
"fallback": "clinician_assessment_required_before_routine_monitoring"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# Новая версия PDF и обратный перевод
|
||||
|
||||
Проверено 3 октября 2026. Новая версия — 19 страниц вместо 14; исходный PDF
|
||||
сохранён без изменений как `questionnaires-mvp-v2.pdf`. Хеши обоих источников —
|
||||
в `source-manifest.json`. Все страницы новой версии прочитаны и просмотрены.
|
||||
Это разбор спецификации и языкового материала, не клиническое согласование.
|
||||
|
||||
Технический этап выполнен после разбора: [реализация и ограничения](../IMPLEMENTATION.md).
|
||||
Ниже сохранены замечания к источнику с обновлённым статусом реализации.
|
||||
|
||||
## Что изменилось и как это влияет на реализацию
|
||||
|
||||
| Раздел новой версии | Изменение | Состояние реализации / дальнейшее действие |
|
||||
|---|---|---|
|
||||
| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Реализован TSQ из приложения Г1 редакции 2023 года: 10 пунктов, порог 6; прежние результаты не переименованы. |
|
||||
| с.3 | Вступление и фильтр травмы PC-PTSD-5 сохранены; при «Нет» TSQ не задаётся | Записывать «не проводился по фильтру», не десять нулевых ответов. Вопрос о травме не является одиннадцатым пунктом TSQ. |
|
||||
| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Дословный текст получен из приложения Г1 редакции 2023 года; актуальность редакции ещё не подтверждена. |
|
||||
| с.5–7 | Обосновано сохранение PCL-5 вместо IES-R; предложено сравнение на пилоте | 20 русских пунктов, инструкции и варианты PCL-5 не изменились; поменялось расположение на страницах. Пилот и валидация не выполнены. |
|
||||
| с.8–10 | RUS-AUDIT-S исправлен на 3/9/10; повтор тоже краткой формой | Совпадает с уже реализованным составом. Временное расширение при любом ненулевом ответе не равно переходу с зоны II. Решение C02 остаётся открытым. |
|
||||
| с.8 | При п.1=0 теперь явно пропускается только п.2 | Исправлено: выводится только п.2=0; п.3 сохраняется. Конфликт останавливает поток для проверки. |
|
||||
| с.10 | В PDF по-прежнему не вписаны зоны краткой формы | В PR они уже есть по рис.3 Буновой: женщины 0–1 / 2–4 / 5 / ≥6; мужчины 0–3 / 4–6 / 7 / ≥8. Это не автоматическое утверждение порога расширения. |
|
||||
| с.11–12 | Добавлена ссылка на русские бланки Columbia | Recent уже получен и сверён в PR. Для мониторинга требуется отдельный официальный Since Last Contact; текущий Recent его не заменяет. |
|
||||
| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Расписания и объединение реализованы, вопрос о переносимости добавлен как кандидатный. Требуются согласование и корректные даты изменений назначений. |
|
||||
| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. Добавлены за закрытым переключателем; определения и якоря ждут согласования. |
|
||||
| с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. |
|
||||
| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Проверка источников дополнена в ../crisis-contacts.md; проверка соединения не выполнена. |
|
||||
|
||||
## Противоречия и недостающие определения
|
||||
|
||||
1. **C01 остаётся:** с.1 требует прекращать опрос при любом «Да», с.12 разрешает
|
||||
продолжать при низком риске. Нужно различить прекращение обычной анкеты,
|
||||
продолжение оценки риска и немедленную помощь. Обновление PDF не является подписью врача.
|
||||
2. **Число вопросов C-SSRS:** на с.11 написано «от 2 до 6», но по показанным
|
||||
переходам q1, q2 и q6 обязательны: минимум три основных вопроса, максимум шесть,
|
||||
плюс условное уточнение давности q6. В данных это до семи отдельных ответов.
|
||||
3. **Дневник:** в таблице ПТСР представлены B, C и E, но нет пункта кластера D.
|
||||
Его нельзя описывать как охватывающий все кластеры PCL-5 или заменяющий полную шкалу.
|
||||
4. **Направление шкал дневника:** интерес, энергия и надежда сформулированы
|
||||
положительно, подавленность — отрицательно. Нужны якоря 0/4 и 0/10 и явное
|
||||
направление каждого показателя; общая сумма сейчас не определена.
|
||||
5. **Среднее за семь дней:** неясно, включаются ли текущие три дня ухудшения,
|
||||
сколько измерений достаточно, как учитывать пропуски и часовой пояс.
|
||||
6. **Ночные флаги:** «кошмары ≥3 или сон <4 часов три ночи подряд» допускает два
|
||||
прочтения длительности. Нужна однозначная группировка условий.
|
||||
7. **Лекарства и пропуски:** нужны «не назначены», несколько препаратов,
|
||||
частичный приём и назначение по необходимости. Порог «2–3 дня без чек-ина»
|
||||
должен стать точным правилом с ответственным и сроком реакции.
|
||||
8. **Нагрузка:** ответ о сне содержит две величины; ядро с модулем и условными
|
||||
уточнениями может превышать обещанные 8–10 ответов и одну минуту. Суицидальные
|
||||
вопросы не должны исчезать из-за чередования модулей.
|
||||
9. **Мониторинг:** определить периоды PCL-5 на каждом расписании, точку сравнения
|
||||
для повторного триажа и действия при пересечении окон. Рост суммы разных
|
||||
версий нельзя автоматически считать сопоставимым изменением.
|
||||
|
||||
Эти замечания являются выводами разбора, а не утверждёнными новыми правилами.
|
||||
Числовые значения дневника пока нельзя переносить в работающий сценарий.
|
||||
|
||||
## Обратный перевод выполнен
|
||||
|
||||
Переведены 32 единицы: PCL-5 — инструкция, поле события, общий вопрос, 20 пунктов
|
||||
и 5 вариантов ответа; блок травмы — вступление, фильтр и 2 варианта ответа.
|
||||
Русские формулировки сверены с новой версией; исключены устаревшие пять вопросов
|
||||
PC-PTSD-5 и его отдельная инструкция о месяце.
|
||||
|
||||
- `backtranslation-draft.md` — удобный для чтения русский/английский текст и комментарии.
|
||||
- `backtranslation-draft.tsv` — зафиксированный результат, с датой подготовки; сведения о проверяющем заполняются при согласовании.
|
||||
- `translation-reconciliation.tsv` — последующая предварительная сверка PCL-5 с оригиналом,
|
||||
поля согласованного русского текста и подписи врача пусты.
|
||||
- `backtranslation-blind.tsv` — отдельный незаполненный пакет для независимого человека.
|
||||
|
||||
Английский оригинал не открывался во время перевода. После сохранения результата
|
||||
проведена отдельная сверка с [официальным бланком VA, с.3](https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf).
|
||||
На обложке указано 29 августа 2023, в нижнем колонтитуле бланка — 18 августа 2023;
|
||||
обе даты записаны без попытки выбрать одну. Четыре единицы блока травмы в этом
|
||||
проходе с английским первоисточником не сопоставлялись.
|
||||
|
||||
Главные замечания к PCL-5 после сверки:
|
||||
|
||||
| Пункт | Что требует внимания |
|
||||
|---|---|
|
||||
| 1–2 | «Тяжёлые» допускает неоднозначное понимание эмоционального воздействия воспоминаний и снов. |
|
||||
| 15 | «Раздражительность» может описывать состояние; английский пункт явно указывает раздражительное поведение. |
|
||||
| 17 | «Всё время» добавляет требование постоянства. |
|
||||
| 18 | «Взвинченность» может пониматься шире, чем повышенная пугливость. |
|
||||
| 20 | Прерывистость сна и трудность сохранять сон близки, но не тождественны. |
|
||||
| Ответ 3 | Русское «Сильно» требует проверки градации интенсивности относительно оригинала и соседних ответов. |
|
||||
|
||||
Русский текст не исправлялся. Перевод имеет статус рабочего варианта и требует
|
||||
независимой проверки. Заключение о языковой эквивалентности, клиническое
|
||||
согласование и психометрическая валидация не выполнены.
|
||||
|
||||
## Что остаётся после этого разбора
|
||||
|
||||
Техническая реализация новой версии подготовлена в PR #53. Полное описание —
|
||||
[IMPLEMENTATION.md](../IMPLEMENTATION.md). Клиническое включение остаётся закрытым:
|
||||
врачебные решения, независимая проверка перевода и операционная проверка линий
|
||||
не выполнены; отдельная русская форма Since Last Contact не получена.
|
||||
|
||||
Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1)
|
||||
через веб-инструмент не дала доступного содержимого; актуальность редакции
|
||||
на дату разбора не подтверждена. Утверждения PDF о рекомендациях не отмечены
|
||||
как независимо проверенные. Наличие ссылки или таблицы не подтверждает
|
||||
клиническое соответствие всего приложения.
|
||||
@@ -0,0 +1,33 @@
|
||||
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
|
||||
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" ""
|
||||
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" ""
|
||||
"trauma_filter" "option_Да" "3" "Да" "" "" "" ""
|
||||
"trauma_filter" "option_Нет" "3" "Нет" "" "" "" ""
|
||||
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" ""
|
||||
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" ""
|
||||
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" ""
|
||||
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" ""
|
||||
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" ""
|
||||
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" ""
|
||||
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" ""
|
||||
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" ""
|
||||
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" ""
|
||||
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" ""
|
||||
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" ""
|
||||
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" ""
|
||||
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" ""
|
||||
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" ""
|
||||
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" ""
|
||||
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" ""
|
||||
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" ""
|
||||
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" ""
|
||||
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" ""
|
||||
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" ""
|
||||
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "" "" "" ""
|
||||
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "" "" "" ""
|
||||
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" ""
|
||||
"pcl5" "option_0" "5" "Совсем нет" "" "" "" ""
|
||||
"pcl5" "option_1" "5" "Немного" "" "" "" ""
|
||||
"pcl5" "option_2" "5" "Умеренно" "" "" "" ""
|
||||
"pcl5" "option_3" "5" "Сильно" "" "" "" ""
|
||||
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" ""
|
||||
|
@@ -0,0 +1,221 @@
|
||||
# Обратный перевод: русский → английский
|
||||
|
||||
Источник: обновлённый PDF, 19 страниц, с.3 и 5–7. **Рабочий вариант, требует независимой проверки и согласования.**
|
||||
|
||||
Русский текст сохранён. 32 единицы: 28 PCL-5 и 4 из блока о травме. TSQ не включён: PDF содержит темы, а не дословные вопросы. Авторский дневник не имеет указанного английского оригинала, поэтому его перевод не является обратным.
|
||||
|
||||
Исходный результат зафиксирован в `backtranslation-draft.tsv`; последующая сверка с оригиналом ведётся отдельно. Пустой `backtranslation-blind.tsv` оставлен для независимого переводчика.
|
||||
|
||||
## trauma_filter / introduction · с. 3
|
||||
|
||||
Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
|
||||
|
||||
**EN:** Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide.
|
||||
|
||||
## trauma_filter / trauma_filter · с. 3
|
||||
|
||||
Случалось ли с Вами подобное событие?
|
||||
|
||||
**EN:** Has an event like this happened to you?
|
||||
|
||||
**Комментарий переводчика:** «С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing.
|
||||
|
||||
## trauma_filter / option_Да · с. 3
|
||||
|
||||
Да
|
||||
|
||||
**EN:** Yes
|
||||
|
||||
## trauma_filter / option_Нет · с. 3
|
||||
|
||||
Нет
|
||||
|
||||
**EN:** No
|
||||
|
||||
## pcl5 / instruction · с. 5
|
||||
|
||||
Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
|
||||
|
||||
**EN:** Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month.
|
||||
|
||||
**Комментарий переводчика:** «Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind.
|
||||
|
||||
## pcl5 / optional_event · с. 5
|
||||
|
||||
Ваше самое тяжёлое событие: ______
|
||||
|
||||
**EN:** The event that was most difficult for you: ______
|
||||
|
||||
## pcl5 / stem · с. 5
|
||||
|
||||
Насколько сильно за последний месяц Вас беспокоили:
|
||||
|
||||
**EN:** Over the past month, how much were you bothered by:
|
||||
|
||||
## pcl5 / pcl5_1 · с. 5
|
||||
|
||||
Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
|
||||
|
||||
**EN:** Recurring distressing and unwanted memories of the stressful event?
|
||||
|
||||
**Комментарий переводчика:** «Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted.
|
||||
|
||||
## pcl5 / pcl5_2 · с. 6
|
||||
|
||||
Повторяющиеся тяжёлые сны о стрессовом событии?
|
||||
|
||||
**EN:** Recurring distressing dreams about the stressful event?
|
||||
|
||||
**Комментарий переводчика:** «Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares.
|
||||
|
||||
## pcl5 / pcl5_3 · с. 6
|
||||
|
||||
Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
|
||||
|
||||
**EN:** Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?
|
||||
|
||||
## pcl5 / pcl5_4 · с. 6
|
||||
|
||||
Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
|
||||
|
||||
**EN:** Becoming very upset when something reminded you of the stressful event?
|
||||
|
||||
**Комментарий переводчика:** «Расстройство» is interpreted as emotional upset, not a diagnosed disorder.
|
||||
|
||||
## pcl5 / pcl5_5 · с. 6
|
||||
|
||||
Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
|
||||
|
||||
**EN:** Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?
|
||||
|
||||
## pcl5 / pcl5_6 · с. 6
|
||||
|
||||
Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
|
||||
|
||||
**EN:** Avoiding memories, thoughts, or feelings associated with the stressful event?
|
||||
|
||||
## pcl5 / pcl5_7 · с. 6
|
||||
|
||||
Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
|
||||
|
||||
**EN:** Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?
|
||||
|
||||
## pcl5 / pcl5_8 · с. 6
|
||||
|
||||
Трудности с тем, чтобы вспомнить важные части стрессового события?
|
||||
|
||||
**EN:** Difficulty remembering important parts of the stressful event?
|
||||
|
||||
## pcl5 / pcl5_9 · с. 6
|
||||
|
||||
Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
|
||||
|
||||
**EN:** Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?
|
||||
|
||||
**Комментарий переводчика:** «Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe.
|
||||
|
||||
## pcl5 / pcl5_10 · с. 6
|
||||
|
||||
Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
|
||||
|
||||
**EN:** Blaming yourself or someone else for the stressful event or for what happened afterward?
|
||||
|
||||
## pcl5 / pcl5_11 · с. 6
|
||||
|
||||
Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
|
||||
|
||||
**EN:** Strong negative feelings such as fear, horror, anger, guilt, or shame?
|
||||
|
||||
## pcl5 / pcl5_12 · с. 6
|
||||
|
||||
Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
|
||||
|
||||
**EN:** Losing interest in activities that used to give you pleasure?
|
||||
|
||||
## pcl5 / pcl5_13 · с. 6
|
||||
|
||||
Ощущение отдалённости или оторванности от других людей?
|
||||
|
||||
**EN:** Feeling distant or cut off from other people?
|
||||
|
||||
## pcl5 / pcl5_14 · с. 7
|
||||
|
||||
Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
|
||||
|
||||
**EN:** Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?
|
||||
|
||||
**Комментарий переводчика:** «Близкие люди» means people close to the respondent, not necessarily relatives.
|
||||
|
||||
## pcl5 / pcl5_15 · с. 7
|
||||
|
||||
Раздражительность, вспышки гнева или агрессивное поведение?
|
||||
|
||||
**EN:** Irritability, outbursts of anger, or aggressive behavior?
|
||||
|
||||
## pcl5 / pcl5_16 · с. 7
|
||||
|
||||
Чрезмерный риск или поступки, которые могут причинить Вам вред?
|
||||
|
||||
**EN:** Excessive risk-taking or actions that could harm you?
|
||||
|
||||
**Комментарий переводчика:** «Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied.
|
||||
|
||||
## pcl5 / pcl5_17 · с. 7
|
||||
|
||||
Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
|
||||
|
||||
**EN:** Increased vigilance, wariness, or a feeling that you need to be on guard all the time?
|
||||
|
||||
## pcl5 / pcl5_18 · с. 7
|
||||
|
||||
Взвинченность или склонность легко вздрагивать?
|
||||
|
||||
**EN:** Feeling keyed up or being easily startled?
|
||||
|
||||
**Комментарий переводчика:** «Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed.
|
||||
|
||||
## pcl5 / pcl5_19 · с. 7
|
||||
|
||||
Трудности с концентрацией внимания?
|
||||
|
||||
**EN:** Difficulty concentrating?
|
||||
|
||||
## pcl5 / pcl5_20 · с. 7
|
||||
|
||||
Трудности с засыпанием или прерывистый сон?
|
||||
|
||||
**EN:** Difficulty falling asleep or interrupted sleep?
|
||||
|
||||
**Комментарий переводчика:** «Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted.
|
||||
|
||||
## pcl5 / option_0 · с. 5
|
||||
|
||||
Совсем нет
|
||||
|
||||
**EN:** Not at all
|
||||
|
||||
## pcl5 / option_1 · с. 5
|
||||
|
||||
Немного
|
||||
|
||||
**EN:** A little
|
||||
|
||||
## pcl5 / option_2 · с. 5
|
||||
|
||||
Умеренно
|
||||
|
||||
**EN:** Moderately
|
||||
|
||||
## pcl5 / option_3 · с. 5
|
||||
|
||||
Сильно
|
||||
|
||||
**EN:** Very much
|
||||
|
||||
**Комментарий переводчика:** Intensity anchor translated as Very much; requires review together with the question stem and other anchors.
|
||||
|
||||
## pcl5 / option_4 · с. 5
|
||||
|
||||
Крайне сильно
|
||||
|
||||
**EN:** Extremely
|
||||
@@ -0,0 +1,33 @@
|
||||
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
|
||||
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide." "" "" "2026-10-03"
|
||||
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "Has an event like this happened to you?" "«С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing." "" "2026-10-03"
|
||||
"trauma_filter" "option_Да" "3" "Да" "Yes" "" "" "2026-10-03"
|
||||
"trauma_filter" "option_Нет" "3" "Нет" "No" "" "" "2026-10-03"
|
||||
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month." "«Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind." "" "2026-10-03"
|
||||
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "The event that was most difficult for you: ______" "" "" "2026-10-03"
|
||||
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "Over the past month, how much were you bothered by:" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "Recurring distressing and unwanted memories of the stressful event?" "«Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted." "" "2026-10-03"
|
||||
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "Recurring distressing dreams about the stressful event?" "«Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares." "" "2026-10-03"
|
||||
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "Becoming very upset when something reminded you of the stressful event?" "«Расстройство» is interpreted as emotional upset, not a diagnosed disorder." "" "2026-10-03"
|
||||
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "Avoiding memories, thoughts, or feelings associated with the stressful event?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "Difficulty remembering important parts of the stressful event?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?" "«Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe." "" "2026-10-03"
|
||||
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "Blaming yourself or someone else for the stressful event or for what happened afterward?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "Strong negative feelings such as fear, horror, anger, guilt, or shame?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "Losing interest in activities that used to give you pleasure?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "Feeling distant or cut off from other people?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?" "«Близкие люди» means people close to the respondent, not necessarily relatives." "" "2026-10-03"
|
||||
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "Irritability, outbursts of anger, or aggressive behavior?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "Excessive risk-taking or actions that could harm you?" "«Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied." "" "2026-10-03"
|
||||
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "Increased vigilance, wariness, or a feeling that you need to be on guard all the time?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "Feeling keyed up or being easily startled?" "«Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed." "" "2026-10-03"
|
||||
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "Difficulty concentrating?" "" "" "2026-10-03"
|
||||
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "Difficulty falling asleep or interrupted sleep?" "«Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted." "" "2026-10-03"
|
||||
"pcl5" "option_0" "5" "Совсем нет" "Not at all" "" "" "2026-10-03"
|
||||
"pcl5" "option_1" "5" "Немного" "A little" "" "" "2026-10-03"
|
||||
"pcl5" "option_2" "5" "Умеренно" "Moderately" "" "" "2026-10-03"
|
||||
"pcl5" "option_3" "5" "Сильно" "Very much" "Intensity anchor translated as Very much; requires review together with the question stem and other anchors." "" "2026-10-03"
|
||||
"pcl5" "option_4" "5" "Крайне сильно" "Extremely" "" "" "2026-10-03"
|
||||
|
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"source": "questionnaires-mvp-v2.pdf",
|
||||
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
|
||||
"pages": 19,
|
||||
"previous_source": "../questionnaires-mvp-source.pdf",
|
||||
"previous_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
|
||||
"units": 32,
|
||||
"scope": "PCL-5 (28 units) and retained trauma introduction/filter/options (4 units)",
|
||||
"excluded": "TSQ contains topic outlines only; five removed PC-PTSD-5 items are obsolete; diary is original team content, not a backtranslation target",
|
||||
"clinician_approval": null,
|
||||
"reference_comparison": {
|
||||
"performed_after_translation_frozen": true,
|
||||
"scope": "28 PCL-5 units",
|
||||
"reference": "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf",
|
||||
"reference_page": 3,
|
||||
"reference_cover_date": "2023-08-29",
|
||||
"reference_form_footer_date": "2023-08-18",
|
||||
"independent_review": "pending"
|
||||
},
|
||||
"independent_review": "not_performed",
|
||||
"backtranslation": {
|
||||
"file": "backtranslation-draft.tsv",
|
||||
"sha256": "4e8bbcde4d6cb8b613a3f1224fa3963dc81922cabfa1fb1560eeeebf714b3ad5",
|
||||
"status": "draft_completed_independent_review_pending",
|
||||
"date": "2026-10-03"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"scale" "item_id" "source_page" "russian_text" "backtranslation" "english_original_reference" "review_flag" "review_note" "agreed_russian_text" "clinician" "decision_date" "status"
|
||||
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide." "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
|
||||
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "Has an event like this happened to you?" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
|
||||
"trauma_filter" "option_Да" "3" "Да" "Yes" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
|
||||
"trauma_filter" "option_Нет" "3" "Нет" "No" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
|
||||
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month." "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Смысл инструкции и месячный период сохранены; «тяжёлый» допускает разные оттенки." "" "" "" "pending_independent_review"
|
||||
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "The event that was most difficult for you: ______" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "Over the past month, how much were you bothered by:" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "Recurring distressing and unwanted memories of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Тяжёлые» менее однозначно передаёт эмоциональное беспокойство, чем disturbing." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "Recurring distressing dreams about the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Та же неоднозначность «тяжёлые» применительно к снам." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "Becoming very upset when something reminded you of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "Avoiding memories, thoughts, or feelings associated with the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "Difficulty remembering important parts of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "Blaming yourself or someone else for the stressful event or for what happened afterward?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "Strong negative feelings such as fear, horror, anger, guilt, or shame?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "Losing interest in activities that used to give you pleasure?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "Feeling distant or cut off from other people?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "Irritability, outbursts of anger, or aggressive behavior?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "priority_review" "Irritable behavior описывает поведение; «раздражительность» может включать внутреннее состояние." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "Excessive risk-taking or actions that could harm you?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "Increased vigilance, wariness, or a feeling that you need to be on guard all the time?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Добавлено «всё время»; это может усилить требование постоянства настороженности." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "Feeling keyed up or being easily startled?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Взвинченность» шире jumpy и может пониматься как общее возбуждение." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "Difficulty concentrating?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "Difficulty falling asleep or interrupted sleep?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Прерывистый сон» описывает результат, staying asleep — способность сохранять сон." "" "" "" "pending_independent_review"
|
||||
"pcl5" "option_0" "5" "Совсем нет" "Not at all" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "option_1" "5" "Немного" "A little" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "option_2" "5" "Умеренно" "Moderately" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
"pcl5" "option_3" "5" "Сильно" "Very much" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "priority_review" "«Сильно» вернулось как Very much; оригинальное Quite a bit может задавать иной уровень интенсивности." "" "" "" "pending_independent_review"
|
||||
"pcl5" "option_4" "5" "Крайне сильно" "Extremely" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
|
||||
|
@@ -0,0 +1,39 @@
|
||||
"scale" "item_id" "source_page" "russian_text" "english_original_reference" "independent_backtranslation" "concept_difference" "severity" "agreed_russian_text" "clinician" "decision_date" "status"
|
||||
"pcptsd5" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "instruction" "3" "За последний месяц Вы…" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "pcptsd5_1" "4" "…видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "pcptsd5_2" "4" "…изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "pcptsd5_3" "4" "…были постоянно настороже, начеку или легко вздрагивали?" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "pcptsd5_4" "4" "…чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "pcptsd5_5" "4" "…чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "option_Да" "3–4" "Да" "" "" "" "" "" "" "" "pending"
|
||||
"pcptsd5" "option_Нет" "3–4" "Нет" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_2" "5" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_3" "5" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_4" "5" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_5" "5" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_6" "5" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_14" "6" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_15" "6" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_16" "6" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_17" "6" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_18" "6" "Взвинченность или склонность легко вздрагивать?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_19" "6" "Трудности с концентрацией внимания?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "option_0" "5" "Совсем нет" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "option_1" "5" "Немного" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "option_2" "5" "Умеренно" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "option_3" "5" "Сильно" "" "" "" "" "" "" "" "pending"
|
||||
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" "" "" "" "" "pending"
|
||||
|
Binary file not shown.
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Protect the reviewed PDF transcription and its connection to Recent scoring."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.cssrs_content import load_cssrs_russian
|
||||
from app.core.questionnaire import score_cssrs_recent
|
||||
|
||||
CLINICAL = Path(__file__).resolve().parents[1] / "docs" / "clinical"
|
||||
|
||||
|
||||
def test_source_hashes_and_complete_transcription():
|
||||
form = load_cssrs_russian()
|
||||
manifest = json.loads((CLINICAL / "review-status.json").read_text())["cssrs_russian_pdf"]
|
||||
assert hashlib.sha256((CLINICAL / manifest["file"]).read_bytes()).hexdigest() == manifest["sha256"]
|
||||
source = (CLINICAL / manifest["source_text"]).read_bytes()
|
||||
assert hashlib.sha256(source).hexdigest() == manifest["source_text_sha256"]
|
||||
assert form.version == manifest["content_version"]
|
||||
# Reconstruct the complete extracted page, including numbering, guidance,
|
||||
# original typos, answer order and the color legend. No wording is dropped.
|
||||
blocks = [form.title, form.subtitle, form.period_label_original, form.initial_instruction]
|
||||
blocks.extend(option.label for option in form.options)
|
||||
for index, item in enumerate(form.items):
|
||||
blocks.append(f"{index + 1}. {item.text}" if index < 6 else item.text)
|
||||
if item.interviewer_guidance:
|
||||
blocks.append(item.interviewer_guidance)
|
||||
if item.id == "q2":
|
||||
blocks.append(form.branch_instruction)
|
||||
blocks.extend(["Low Risk", "Moderate Risk", "High Risk"])
|
||||
assert " ".join(blocks) == source.decode().strip()
|
||||
assert [item.recall_period for item in form.items] == ["past_month"] * 5 + ["lifetime", "past_3_months"]
|
||||
|
||||
|
||||
def test_all_source_branches_and_color_categories_match_scorer():
|
||||
form = load_cssrs_russian()
|
||||
colors = {item.id: item.yes_risk for item in form.items}
|
||||
assert list(colors.values()) == ["low", "low", "moderate", "high", "high", "moderate", "high"]
|
||||
levels = ["none", "low", "moderate", "high"]
|
||||
for values in product((0, 1), repeat=7):
|
||||
all_answers = dict(zip(colors, values, strict=True))
|
||||
asked = ["q1", "q2"]
|
||||
if all_answers["q2"]:
|
||||
asked.extend(["q3", "q4", "q5"])
|
||||
asked.append("q6_lifetime")
|
||||
if all_answers["q6_lifetime"]:
|
||||
asked.append("q6_recent")
|
||||
answers = {}
|
||||
for item_id in asked:
|
||||
partial = score_cssrs_recent(answers)
|
||||
assert not partial.complete
|
||||
assert partial.missing[0] == item_id
|
||||
answers[item_id] = all_answers[item_id]
|
||||
result = score_cssrs_recent(answers)
|
||||
expected = max((levels.index(colors[key]) for key, value in answers.items() if value), default=0)
|
||||
assert result.complete and result.risk == levels[expected]
|
||||
assert result.required == tuple(asked)
|
||||
@@ -67,6 +67,9 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
|
||||
"safety_plans",
|
||||
"clock_state",
|
||||
"audit_log",
|
||||
"questionnaire_records",
|
||||
"questionnaire_notices",
|
||||
"questionnaire_tasks",
|
||||
} == clinical_tables
|
||||
await init_db() # idempotent
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Synthetic boundary, branching and safety regression cases for intake (#52)."""
|
||||
|
||||
from itertools import product
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.questionnaire import (
|
||||
IntakePolicy,
|
||||
alcohol_expansion,
|
||||
evaluate_intake,
|
||||
extend_alcohol,
|
||||
load_policy,
|
||||
score_alcohol,
|
||||
score_cssrs_recent,
|
||||
)
|
||||
from app.core.scales import load_scale
|
||||
|
||||
|
||||
def approved(mode="graded"):
|
||||
data = load_policy().model_dump()
|
||||
data.update(cssrs_positive_policy=mode, rus_audit_s_expand_from_zone=2)
|
||||
data["approval"] = {
|
||||
"reviewer": "SYNTHETIC TEST ONLY",
|
||||
"decision_reference": "test-fixture-not-clinical-approval",
|
||||
"approved_at": "2026-10-03T12:00:00+03:00",
|
||||
"policy_version": data["version"],
|
||||
}
|
||||
return IntakePolicy.model_validate(data)
|
||||
|
||||
|
||||
def zeros(scale):
|
||||
return {key: 0 for key in load_scale(scale).item_ids}
|
||||
|
||||
|
||||
def intake(**changes):
|
||||
args = dict(
|
||||
phq9={"phq9_1": 0, "phq9_2": 0},
|
||||
tsq={},
|
||||
pcl5={},
|
||||
alcohol=zeros("rus_audit_s"),
|
||||
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0},
|
||||
sex="f",
|
||||
trauma_exposure=False,
|
||||
policy=approved(),
|
||||
)
|
||||
args.update(changes)
|
||||
return evaluate_intake(**args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sex", ["f", "m"])
|
||||
def test_every_short_alcohol_answer_combination(sex):
|
||||
# Exhaustively cover all valid patterns, including sparse 0/2/4 history options.
|
||||
for q3, q9, q10 in product(range(5), (0, 2, 4), (0, 2, 4)):
|
||||
total = q3 + q9 + q10
|
||||
expected = (
|
||||
(1 if total < 2 else 2 if total < 5 else 3 if total == 5 else 4)
|
||||
if sex == "f"
|
||||
else (1 if total < 4 else 2 if total < 7 else 3 if total == 7 else 4)
|
||||
)
|
||||
result = score_alcohol(
|
||||
dict(zip(load_scale("rus_audit_s").item_ids, (q3, q9, q10), strict=True)), short=True, sex=sex
|
||||
)
|
||||
assert result.complete and result.total == total and result.zone == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sex,bounds",
|
||||
[
|
||||
("f", [(0, 1), (4, 1), (5, 2), (9, 2), (10, 3), (11, 4), (40, 4)]),
|
||||
("m", [(0, 1), (8, 1), (9, 2), (13, 2), (14, 3), (16, 3), (17, 4), (40, 4)]),
|
||||
],
|
||||
)
|
||||
def test_full_alcohol_boundaries(sex, bounds):
|
||||
for total, expected in bounds:
|
||||
values = zeros("rus_audit")
|
||||
remaining = total
|
||||
for item in load_scale("rus_audit").items:
|
||||
value = max(o.value for o in item.options if o.value <= remaining)
|
||||
values[item.id] = value
|
||||
remaining -= value
|
||||
assert remaining == 0
|
||||
assert score_alcohol(values, short=False, sex=sex).zone == expected
|
||||
|
||||
|
||||
def test_alcohol_missing_unknown_sex_and_no_implicit_approval():
|
||||
r = score_alcohol({"rus_audit_3": 0}, short=True, sex="f")
|
||||
assert not r.complete and r.total is None and r.zone is None
|
||||
r = score_alcohol(zeros("rus_audit_s"), short=True, sex=None)
|
||||
assert r.complete and r.total == 0 and r.zone is None
|
||||
r = score_alcohol({"rus_audit_3": 4, "rus_audit_9": 4, "rus_audit_10": 4}, short=True, sex="m")
|
||||
assert alcohol_expansion(r, load_policy()) is None
|
||||
assert alcohol_expansion(r, approved()) is True
|
||||
|
||||
|
||||
def test_full_extension_preserves_history_and_never_counts_twice():
|
||||
short = {"rus_audit_3": 0, "rus_audit_9": 2, "rus_audit_10": 4}
|
||||
extra = {"rus_audit_1": 0, **{f"rus_audit_{i}": 0 for i in range(4, 9)}, "rus_audit_9": 2}
|
||||
merged = extend_alcohol(short, extra)
|
||||
result = score_alcohol(merged, short=False, sex="f")
|
||||
assert result.complete and result.total == 6
|
||||
assert result.derived == ("rus_audit_2",)
|
||||
assert "rus_audit_2" not in merged # caller-owned raw answers are not mutated
|
||||
with pytest.raises(ValueError, match="conflicting"):
|
||||
extend_alcohol(short, {"rus_audit_9": 0})
|
||||
with pytest.raises(ValueError, match="conflicts"):
|
||||
score_alcohol({"rus_audit_1": 0, "rus_audit_3": 1}, short=False, sex="m")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [True, "1", 1.0, -1, 5])
|
||||
def test_alcohol_rejects_invalid_answers(bad):
|
||||
with pytest.raises(ValueError):
|
||||
score_alcohol({"rus_audit_3": bad}, short=True, sex="f")
|
||||
|
||||
|
||||
def test_cannot_use_auditc_items_or_sparse_history_values():
|
||||
for values in [{"rus_audit_1": 1}, {"auditc_1": 1}, {"rus_audit_9": 1}]:
|
||||
with pytest.raises(ValueError):
|
||||
score_alcohol(values, short=True, sex="f")
|
||||
|
||||
|
||||
def test_cssrs_negative_branch_is_complete_without_inventing_zeroes():
|
||||
a = {"q1": 0, "q2": 0, "q6_lifetime": 0}
|
||||
r = score_cssrs_recent(a)
|
||||
assert r.complete and r.risk == "none"
|
||||
assert r.required == ("q1", "q2", "q6_lifetime")
|
||||
assert set(a) == set(r.required)
|
||||
|
||||
|
||||
def test_cssrs_remote_behavior_is_not_negative_and_missing_recency_not_low():
|
||||
a = {"q1": 0, "q2": 0, "q6_lifetime": 1}
|
||||
r = score_cssrs_recent(a)
|
||||
assert not r.complete and r.risk is None and r.risk_lower_bound == "moderate"
|
||||
assert r.missing == ("q6_recent",)
|
||||
assert score_cssrs_recent({**a, "q6_recent": 0}).risk == "moderate"
|
||||
assert score_cssrs_recent({**a, "q6_recent": 1}).risk == "high"
|
||||
|
||||
|
||||
def test_cssrs_ideation_branch_and_partial_high():
|
||||
assert score_cssrs_recent({}).risk is None
|
||||
assert score_cssrs_recent({"q2": 1}).missing == ("q1", "q3", "q4", "q5", "q6_lifetime")
|
||||
r = score_cssrs_recent({"q4": 1})
|
||||
assert not r.complete and r.risk is None and r.risk_lower_bound == "high"
|
||||
|
||||
|
||||
def test_cssrs_exhaustive_complete_ideation_patterns():
|
||||
for q1, q3, q4, q5, lifetime, recent in product((0, 1), repeat=6):
|
||||
a = dict(q1=q1, q2=1, q3=q3, q4=q4, q5=q5, q6_lifetime=lifetime)
|
||||
if lifetime:
|
||||
a["q6_recent"] = recent
|
||||
expected = "high" if q4 or q5 or (lifetime and recent) else "moderate" if q3 or lifetime else "low"
|
||||
r = score_cssrs_recent(a)
|
||||
assert r.complete and r.risk == expected
|
||||
|
||||
|
||||
def test_cssrs_conflict_preserves_high_signal():
|
||||
r = score_cssrs_recent({"q1": 0, "q2": 0, "q4": 1, "q6_lifetime": 0, "q6_recent": 1})
|
||||
assert not r.complete and r.risk_lower_bound == "high" and len(r.conflicts) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, True, "Да", 1.0, 2, -1])
|
||||
def test_cssrs_requires_explicit_binary_integer(bad):
|
||||
with pytest.raises(ValueError):
|
||||
score_cssrs_recent({"q1": bad})
|
||||
|
||||
|
||||
def test_unapproved_policy_never_continues_even_a_negative_screen():
|
||||
r = intake(policy=load_policy())
|
||||
assert r.status == "clinical_review_required" and r.primary_route is None
|
||||
assert intake(policy=load_policy(), cssrs={"q1": 1}).status == "clinical_review_required"
|
||||
|
||||
|
||||
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
|
||||
for overrides in [
|
||||
dict(help_now=True, cssrs={"broken": "data"}),
|
||||
dict(phq9={"phq9_9": 1}),
|
||||
dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
|
||||
]:
|
||||
assert intake(policy=load_policy(), **overrides).status == "crisis"
|
||||
r = intake(phq9={"phq9_9": 1})
|
||||
assert "phq9_cssrs_discrepancy" in r.flags
|
||||
|
||||
|
||||
def test_physician_must_choose_between_stop_and_graded():
|
||||
low = {"q1": 1, "q2": 0, "q6_lifetime": 0}
|
||||
assert intake(cssrs=low, policy=approved("stop_any")).status == "crisis"
|
||||
r = intake(cssrs=low, policy=approved("graded"))
|
||||
assert r.status == "ready" and "cssrs_low_safety_plan" in r.flags
|
||||
data = approved().model_dump()
|
||||
data["version"] = "changed"
|
||||
with pytest.raises(ValueError, match="matching version"):
|
||||
IntakePolicy.model_validate(data)
|
||||
|
||||
|
||||
def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
|
||||
assert intake().primary_route == "self_help"
|
||||
r = intake(phq9={"phq9_1": 1, "phq9_2": 1})
|
||||
assert r.status == "incomplete" and "phq9_9" in r.missing
|
||||
assert intake(trauma_exposure=None).status == "incomplete"
|
||||
assert intake(trauma_exposure=True, tsq={"tsq_1": 1}).status == "incomplete"
|
||||
pc = {f"tsq_{i}": int(i <= 6) for i in range(1, 11)}
|
||||
assert intake(trauma_exposure=True, tsq=pc).status == "incomplete"
|
||||
assert intake(trauma_exposure=True, tsq=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"total,route",
|
||||
[
|
||||
(30, "self_help"),
|
||||
(31, "psychiatrist_or_psychotherapist"),
|
||||
(49, "psychiatrist_or_psychotherapist"),
|
||||
(50, "priority_psychiatrist"),
|
||||
],
|
||||
)
|
||||
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
|
||||
values = zeros("pcl5")
|
||||
for key in values:
|
||||
values[key] = min(4, total)
|
||||
total -= values[key]
|
||||
assert intake(pcl5=values).primary_route == route
|
||||
|
||||
|
||||
def test_urgent_route_and_parallel_referrals_do_not_overwrite_each_other():
|
||||
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
|
||||
r = intake(
|
||||
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 1, "q6_recent": 0},
|
||||
alcohol=alcohol,
|
||||
traumatic_brain_injury=True,
|
||||
psychotropics=True,
|
||||
)
|
||||
assert r.primary_route == "urgent_psychiatrist"
|
||||
assert r.parallel_routes == ("narcologist", "neurologist")
|
||||
assert "alcohol_psychotropics" in r.flags
|
||||
|
||||
|
||||
def test_alcohol_expansion_and_unknown_sex_block_routine_route():
|
||||
assert intake(alcohol={"rus_audit_3": 2, "rus_audit_9": 0, "rus_audit_10": 0}).status == "incomplete"
|
||||
r = intake(sex=None)
|
||||
assert r.status == "incomplete" and "alcohol_scoring_sex" in r.missing
|
||||
|
||||
|
||||
def test_non_drinker_does_not_derive_missing_short_answer():
|
||||
values = zeros("rus_audit")
|
||||
del values["rus_audit_2"]
|
||||
del values["rus_audit_3"]
|
||||
assert intake(alcohol=values).status == "incomplete"
|
||||
assert "rus_audit_3" in intake(alcohol=values).missing
|
||||
|
||||
|
||||
def test_crisis_keeps_other_flags_and_bad_data_cannot_hide_explicit_signal():
|
||||
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
|
||||
r = intake(cssrs={"q4": 1}, pcl5={"pcl5_15": 3}, alcohol=alcohol, traumatic_brain_injury=True, psychotropics=True)
|
||||
assert r.status == "crisis"
|
||||
assert {"pcl5_behavior", "alcohol_zone_iv", "alcohol_psychotropics", "traumatic_brain_injury"} <= set(r.flags)
|
||||
assert intake(cssrs={"q4": 1, "invalid": 7}).status == "crisis"
|
||||
assert intake(phq9={"phq9_9": 1}, cssrs={"invalid": 7}).status == "crisis"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"total,route",
|
||||
[
|
||||
(4, "self_help"),
|
||||
(5, "psychologist"),
|
||||
(9, "psychologist"),
|
||||
(10, "psychiatrist_or_psychotherapist"),
|
||||
(19, "psychiatrist_or_psychotherapist"),
|
||||
(20, "priority_psychiatrist"),
|
||||
(24, "priority_psychiatrist"),
|
||||
],
|
||||
)
|
||||
def test_phq_routes_at_each_boundary_without_positive_item9(total, route):
|
||||
values = zeros("phq9")
|
||||
for key in list(values)[:8]:
|
||||
values[key] = min(3, total)
|
||||
total -= values[key]
|
||||
assert intake(phq9=values).primary_route == route
|
||||
@@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None:
|
||||
assert isinstance(script, CrisisScript)
|
||||
assert script.variant == "patient.default"
|
||||
assert "112" in script.text
|
||||
assert "8-800-2000-122" in script.text
|
||||
assert "бесплатно, круглосуточно" in script.text
|
||||
assert "уведомлён" in script.text
|
||||
assert "+7 (495) 989-50-50" in script.text
|
||||
assert "круглосуточно" in script.text
|
||||
assert "по тарифу оператора" in script.text
|
||||
assert "уведомлён" not in script.text
|
||||
assert script.grounding in script.text
|
||||
assert "психиатр (#8)" in script.status
|
||||
|
||||
@@ -169,7 +170,7 @@ def test_crisis_reply_patient_default() -> None:
|
||||
def test_crisis_reply_variants() -> None:
|
||||
dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
|
||||
assert dv.variant == "family.domestic_violence"
|
||||
assert "потерпеть" in dv.text and "112" in dv.text and "8-800-2000-122" in dv.text
|
||||
assert "потерпеть" in dv.text and "112" in dv.text and "+7 (495) 989-50-50" in dv.text
|
||||
assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence"
|
||||
assert crisis_reply(["harm_others"]).variant == "patient.harm_others"
|
||||
assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
|
||||
|
||||
@@ -30,6 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
|
||||
def test_all_scales_load_with_expected_item_counts() -> None:
|
||||
counts = {k: len(v.items) for k, v in load_all().items()}
|
||||
expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5}
|
||||
expected.update(rus_audit_s=3, rus_audit=10, tsq=10)
|
||||
assert counts == expected
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user