feat(questionnaire): revised intake, diaries and follow-up monitoring #53

Merged
Eleboba merged 7 commits from feat/52-questionnaire into dev 2026-10-03 18:41:32 +03:00
12 changed files with 1005 additions and 0 deletions
Showing only changes of commit 3cd7e2fd75 - Show all commits
+334
View File
@@ -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), ())
+13
View File
@@ -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
+58
View File
@@ -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"}
+41
View File
@@ -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"}
+155
View File
@@ -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, пороги маршрутов, независимые направления и отсутствие
разрешения на автоматический переход без реального решения врача.
+39
View File
@@ -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 Крайне сильно
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 pcptsd5 introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
3 pcptsd5 trauma_filter 3 Случалось ли с Вами подобное событие?
4 pcptsd5 instruction 3 За последний месяц Вы…
5 pcptsd5 pcptsd5_1 4 …видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?
6 pcptsd5 pcptsd5_2 4 …изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?
7 pcptsd5 pcptsd5_3 4 …были постоянно настороже, начеку или легко вздрагивали?
8 pcptsd5 pcptsd5_4 4 …чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?
9 pcptsd5 pcptsd5_5 4 …чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?
10 pcptsd5 option_Да 3–4 Да
11 pcptsd5 option_Нет 3–4 Нет
12 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
13 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______
14 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили:
15 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
16 pcl5 pcl5_2 5 Повторяющиеся тяжёлые сны о стрессовом событии?
17 pcl5 pcl5_3 5 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
18 pcl5 pcl5_4 5 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
19 pcl5 pcl5_5 5 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
20 pcl5 pcl5_6 5 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
21 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
22 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события?
23 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
24 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
25 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
26 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
27 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей?
28 pcl5 pcl5_14 6 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
29 pcl5 pcl5_15 6 Раздражительность, вспышки гнева или агрессивное поведение?
30 pcl5 pcl5_16 6 Чрезмерный риск или поступки, которые могут причинить Вам вред?
31 pcl5 pcl5_17 6 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
32 pcl5 pcl5_18 6 Взвинченность или склонность легко вздрагивать?
33 pcl5 pcl5_19 6 Трудности с концентрацией внимания?
34 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон?
35 pcl5 option_0 5 Совсем нет
36 pcl5 option_1 5 Немного
37 pcl5 option_2 5 Умеренно
38 pcl5 option_3 5 Сильно
39 pcl5 option_4 5 Крайне сильно
+34
View File
@@ -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.
+17
View File
@@ -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
1 scale item_id source_page russian_text english_original_reference independent_backtranslation concept_difference severity agreed_russian_text clinician decision_date status
2 pcptsd5 introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. pending
3 pcptsd5 trauma_filter 3 Случалось ли с Вами подобное событие? pending
4 pcptsd5 instruction 3 За последний месяц Вы… pending
5 pcptsd5 pcptsd5_1 4 …видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого? pending
6 pcptsd5 pcptsd5_2 4 …изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали? pending
7 pcptsd5 pcptsd5_3 4 …были постоянно настороже, начеку или легко вздрагивали? pending
8 pcptsd5 pcptsd5_4 4 …чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает? pending
9 pcptsd5 pcptsd5_5 4 …чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать? pending
10 pcptsd5 option_Да 3–4 Да pending
11 pcptsd5 option_Нет 3–4 Нет pending
12 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. pending
13 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ pending
14 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: pending
15 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? pending
16 pcl5 pcl5_2 5 Повторяющиеся тяжёлые сны о стрессовом событии? pending
17 pcl5 pcl5_3 5 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? pending
18 pcl5 pcl5_4 5 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? pending
19 pcl5 pcl5_5 5 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? pending
20 pcl5 pcl5_6 5 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? pending
21 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? pending
22 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? pending
23 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? pending
24 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? pending
25 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? pending
26 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? pending
27 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? pending
28 pcl5 pcl5_14 6 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? pending
29 pcl5 pcl5_15 6 Раздражительность, вспышки гнева или агрессивное поведение? pending
30 pcl5 pcl5_16 6 Чрезмерный риск или поступки, которые могут причинить Вам вред? pending
31 pcl5 pcl5_17 6 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? pending
32 pcl5 pcl5_18 6 Взвинченность или склонность легко вздрагивать? pending
33 pcl5 pcl5_19 6 Трудности с концентрацией внимания? pending
34 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? pending
35 pcl5 option_0 5 Совсем нет pending
36 pcl5 option_1 5 Немного pending
37 pcl5 option_2 5 Умеренно pending
38 pcl5 option_3 5 Сильно pending
39 pcl5 option_4 5 Крайне сильно pending
+274
View File
@@ -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
+1
View File
@@ -30,6 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
def test_all_scales_load_with_expected_item_counts() -> None: def test_all_scales_load_with_expected_item_counts() -> None:
counts = {k: len(v.items) for k, v in load_all().items()} counts = {k: len(v.items) for k, v in load_all().items()}
expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5} expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5}
expected.update(rus_audit_s=3, rus_audit=10)
assert counts == expected assert counts == expected