feat(questionnaire): revised intake, diaries and follow-up monitoring #53
@@ -0,0 +1,334 @@
|
||||
"""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_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
|
||||
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
|
||||
pcptsd5_expand_at: int = Field(ge=1, le=5, strict=True)
|
||||
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
|
||||
):
|
||||
raise ValueError("approval requires the matching version and resolved clinical decisions")
|
||||
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 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
|
||||
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:
|
||||
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],
|
||||
pcptsd5: 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("pcptsd5", pcptsd5)
|
||||
pcl = score("pcl5", pcl5)
|
||||
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
|
||||
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
|
||||
if "rus_audit_3" in full_alcohol.derived:
|
||||
short_values["rus_audit_3"] = 0
|
||||
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
|
||||
missing = list(c.missing)
|
||||
phq2_items = ("phq9_1", "phq9_2")
|
||||
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 any(pcptsd5.values()):
|
||||
flags.append("trauma_filter_conflict")
|
||||
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.pcptsd5_expand_at
|
||||
if trauma_exposure is True and not pc.complete:
|
||||
missing.extend(k for k in load_scale("pcptsd5").item_ids if k not in pcptsd5)
|
||||
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), ())
|
||||
@@ -0,0 +1,13 @@
|
||||
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
|
||||
version: mvp-review-2026-10-03
|
||||
approval: null
|
||||
cssrs_positive_policy: pending
|
||||
rus_audit_s_expand_from_zone: null
|
||||
phq2_expand_at: 2
|
||||
pcptsd5_expand_at: 3
|
||||
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,155 @@
|
||||
# Опросник: пакет проверки от 3 октября 2026
|
||||
|
||||
Статус: **подготовлено для проверки, не утверждено для пациентов**. Задача #52;
|
||||
клиническое согласование остаётся в #8. Независимый обратный перевод, согласование
|
||||
врачом и проверка линий звонками не выполнены. В `review-status.json` все эти
|
||||
статусы оставлены незавершёнными.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
`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: источник и незавершённая часть
|
||||
|
||||
Официальная [страница переводов 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).
|
||||
Ожидаемый файл: `C-SSRS Screener - Russian.pdf`. **Файл не получен, официальный
|
||||
русский текст не добавлен**: проверка административной политики браузера не
|
||||
доступна; обход блокировки не выполнялся.
|
||||
|
||||
`config/scales/cssrs_screen.yaml` остаётся историческим демо-переводом. Его q6
|
||||
спрашивает только о последних трёх месяцах; автоматически конвертировать такой
|
||||
ответ в пожизненный нельзя. Не выдавать этот файл за официальный перевод.
|
||||
|
||||
Новая структура данных использует q1…q5, q6_lifetime и q6_recent. Проверена по
|
||||
[официальному 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/):
|
||||
q1/q2 всем; при q2=0 перейти к q6; при q2=1 спросить q3–q5;
|
||||
q6 за всю жизнь всем; при положительном q6 уточнить последние три месяца.
|
||||
Поведение ранее трёх месяцев учитывается отдельно и не становится «риска нет».
|
||||
|
||||
Для завершения: получить русский PDF из официальной папки, сохранить хеш/дату/
|
||||
версию, посимвольно сверить вопросы, пояснения, периоды и варианты, проверить
|
||||
переходы на отрисованном бланке, затем добавить отдельную версию русской шкалы.
|
||||
Версию «с последнего контакта» нельзя получить простой заменой заголовка Recent.
|
||||
Статус лицензии и применимость к коммерческому продукту подтверждаются отдельно.
|
||||
|
||||
## Независимый обратный перевод
|
||||
|
||||
Передать переводчику **только** `backtranslation-blind.tsv` и следующие указания:
|
||||
|
||||
1. Нужен независимый переводчик со свободным английским, не автор исходного
|
||||
русского текста. Перевести каждую из 38 единиц буквально по смыслу, включая
|
||||
инструкции, фильтр, вопросы и варианты ответа. Не искать и не подглядывать
|
||||
в английский оригинал. Сохранить ID и порядок строк.
|
||||
2. Заполнить `english_backtranslation`, сомнения в `translator_comment`, имя и
|
||||
дату. Не «улучшать» русские формулировки. Неясный смысл отмечать явно.
|
||||
3. Вернуть файл координатору. Если оригинал уже знаком переводчику, это указать:
|
||||
такую работу нельзя считать слепой независимой проверкой.
|
||||
|
||||
Координатор и врач используют `translation-reconciliation.tsv` после получения
|
||||
обратного перевода. Сопоставить смысл, отрицания, интенсивность, временной период,
|
||||
привязку к событию и примеры с [PC-PTSD-5 VA](https://www.ptsd.va.gov/professional/assessment/screens/pc-ptsd.asp)
|
||||
и [PCL-5 VA](https://www.ptsd.va.gov/professional/assessment/adult-sr/ptsd-checklist.asp).
|
||||
По каждой строке записать расхождение, тяжесть, согласованный русский текст,
|
||||
врача и дату решения. Не заполнять английские поля машинным переводом под видом
|
||||
независимой работы. Одна обратная проверка не доказывает психометрическую валидацию.
|
||||
|
||||
Транскрипция взята из приложенного `questionnaires-mvp-source.pdf`, с.3–7;
|
||||
нормализованы только переносы и межсловные пробелы. SHA-256 оригинала записан в
|
||||
`review-status.json`. В репозитории текущие PC-PTSD-5/PCL-5 отличаются от PDF;
|
||||
после согласования переносить именно утверждённую версию и зафиксировать diff.
|
||||
Английские оригиналы не включены в слепой файл.
|
||||
|
||||
## Решения врача — поля намеренно не заполнены
|
||||
|
||||
| ID | Решение, которое нужно зафиксировать | Статус |
|
||||
|---|---|---|
|
||||
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. PDF с.1 против с.11, скриншот поддерживает остановку. | pending |
|
||||
| C02 | С какой зоны RUS-AUDIT-S дополнять полную форму? Не переносить порог AUDIT-C ≥3/≥4. | pending |
|
||||
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
|
||||
| C04 | PC-PTSD-5 ≥3 и PCL-5 ≥31 для этой популяции; в текущем репозитории PCL-5 ≥33. | pending |
|
||||
| C05 | Привязка PHQ-9/PCL-5 к специалистам, сроки срочной помощи, параллельные маршруты и состав флагов. | pending |
|
||||
| C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending |
|
||||
| C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending |
|
||||
| C08 | Повторы, ухудшение, разные периоды оценки и выход из кризисного режима. | 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, пороги маршрутов, независимые направления и отсутствие
|
||||
разрешения на автоматический переход без реального решения врача.
|
||||
@@ -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,34 @@
|
||||
# Проверка контактов: 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` номер 8-800-2000-122 ошибочно подписан
|
||||
просто «Телефон доверия» для всех пациентов. Взрослой ветке нужен ресурс для взрослых;
|
||||
детский оставить с точной аудиторией. Замена номера и текстов в активном диалоге
|
||||
должна быть согласована с владельцем кризисного сценария и врачом (#8).
|
||||
Этот пакет проверок сам по себе активный кризисный экран не меняет.
|
||||
|
||||
Номер 112 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная
|
||||
ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих
|
||||
линий согласовать с оператором службы проверку доступности без имитации кризиса.
|
||||
|
||||
## Журнал операционной проверки (не выполнена)
|
||||
|
||||
| Служба | Проверяющий | Дата/часовой пояс | Регион/оператор связи | Доступность/часы/стоимость | Альтернативный ресурс | Результат |
|
||||
|---|---|---|---|---|---|---|
|
||||
| МЧС | | | | | | pending |
|
||||
| МСППН | | | | | | pending |
|
||||
| Детский телефон доверия | | | | | | pending |
|
||||
|
||||
Отдельно подтвердить наличие живого дежурного в самом продукте и время реакции.
|
||||
Кнопка, мок или очередь уведомлений не являются подтверждённым контактом с человеком.
|
||||
За пределами России приложение должно уточнять страну и давать местные экстренные
|
||||
ресурсы, а не обещать доступность российских коротких номеров.
|
||||
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"prepared_on": "2026-10-03",
|
||||
"source_pdf_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
|
||||
"source_pdf": "questionnaires-mvp-source.pdf",
|
||||
"translation_status": "not_performed",
|
||||
"clinician_approval": null,
|
||||
"phone_checks": "not_performed",
|
||||
"cssrs_russian_pdf": {
|
||||
"expected_filename": "C-SSRS Screener - Russian.pdf",
|
||||
"downloaded": false,
|
||||
"text_verified": false,
|
||||
"source": "https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/",
|
||||
"blocker": "Browser admin-enforced security check unavailable; not bypassed"
|
||||
},
|
||||
"intake_release_ready": false,
|
||||
"policy_version": "mvp-review-2026-10-03"
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,274 @@
|
||||
"""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},
|
||||
pcptsd5={},
|
||||
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, pcptsd5={"pcptsd5_1": 1}).status == "incomplete"
|
||||
pc = {f"pcptsd5_{i}": int(i <= 3) for i in range(1, 6)}
|
||||
assert intake(trauma_exposure=True, pcptsd5=pc).status == "incomplete"
|
||||
assert intake(trauma_exposure=True, pcptsd5=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
|
||||
|
||||
|
||||
@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_full_non_drinker_derivation_also_satisfies_short_stage():
|
||||
values = zeros("rus_audit")
|
||||
del values["rus_audit_2"]
|
||||
del values["rus_audit_3"]
|
||||
assert intake(alcohol=values).status == "ready"
|
||||
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
assert counts == expected
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user