diff --git a/app/core/questionnaire.py b/app/core/questionnaire.py new file mode 100644 index 0000000..f12137c --- /dev/null +++ b/app/core/questionnaire.py @@ -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), ()) diff --git a/config/questionnaire.yaml b/config/questionnaire.yaml new file mode 100644 index 0000000..263f080 --- /dev/null +++ b/config/questionnaire.yaml @@ -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 diff --git a/config/scales/rus_audit.yaml b/config/scales/rus_audit.yaml new file mode 100644 index 0000000..32573be --- /dev/null +++ b/config/scales/rus_audit.yaml @@ -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"} diff --git a/config/scales/rus_audit_s.yaml b/config/scales/rus_audit_s.yaml new file mode 100644 index 0000000..700bebc --- /dev/null +++ b/config/scales/rus_audit_s.yaml @@ -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"} diff --git a/docs/clinical/README.md b/docs/clinical/README.md new file mode 100644 index 0000000..3c08e12 --- /dev/null +++ b/docs/clinical/README.md @@ -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, пороги маршрутов, независимые направления и отсутствие +разрешения на автоматический переход без реального решения врача. diff --git a/docs/clinical/backtranslation-blind.tsv b/docs/clinical/backtranslation-blind.tsv new file mode 100644 index 0000000..4afe17c --- /dev/null +++ b/docs/clinical/backtranslation-blind.tsv @@ -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 Крайне сильно diff --git a/docs/clinical/crisis-contacts.md b/docs/clinical/crisis-contacts.md new file mode 100644 index 0000000..6da9c82 --- /dev/null +++ b/docs/clinical/crisis-contacts.md @@ -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 | + +Отдельно подтвердить наличие живого дежурного в самом продукте и время реакции. +Кнопка, мок или очередь уведомлений не являются подтверждённым контактом с человеком. +За пределами России приложение должно уточнять страну и давать местные экстренные +ресурсы, а не обещать доступность российских коротких номеров. diff --git a/docs/clinical/questionnaires-mvp-source.pdf b/docs/clinical/questionnaires-mvp-source.pdf new file mode 100644 index 0000000..e5fb118 Binary files /dev/null and b/docs/clinical/questionnaires-mvp-source.pdf differ diff --git a/docs/clinical/review-status.json b/docs/clinical/review-status.json new file mode 100644 index 0000000..c711355 --- /dev/null +++ b/docs/clinical/review-status.json @@ -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" +} diff --git a/docs/clinical/translation-reconciliation.tsv b/docs/clinical/translation-reconciliation.tsv new file mode 100644 index 0000000..45504fd --- /dev/null +++ b/docs/clinical/translation-reconciliation.tsv @@ -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 diff --git a/tests/test_questionnaire.py b/tests/test_questionnaire.py new file mode 100644 index 0000000..b0fde7d --- /dev/null +++ b/tests/test_questionnaire.py @@ -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 diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 81ee105..eed5f99 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -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