diff --git a/app/core/cssrs_content.py b/app/core/cssrs_content.py new file mode 100644 index 0000000..c50fd0a --- /dev/null +++ b/app/core/cssrs_content.py @@ -0,0 +1,59 @@ +"""Russian Recent source transcription, separate from the legacy sequential scale. + +This content does not authorize administering or continuing the questionnaire. +Use questionnaire.score_cssrs_recent for required/missing items and risk, and +evaluate_intake for the clinical policy gate before presenting another question. +Never render interviewer guidance as if it were another patient question. +""" + +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.config import get_settings + +ItemId = Literal["q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"] + + +class SourceModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class CssrsOption(SourceModel): + value: int = Field(strict=True, ge=0, le=1) + label: str + + +class CssrsItem(SourceModel): + id: ItemId + text: str = Field(min_length=1) + interviewer_guidance: str | None + recall_period: Literal["past_month", "lifetime", "past_3_months"] + yes_risk: Literal["low", "moderate", "high"] + + +class CssrsRussianForm(SourceModel): + id: Literal["cssrs_recent_ru"] + version: str = Field(min_length=1) + title: str + subtitle: Literal["Screen Version - Recent"] + period_label_original: str + initial_instruction: str + branch_instruction: str + options: tuple[CssrsOption, CssrsOption] + items: tuple[CssrsItem, ...] + + @model_validator(mode="after") + def _structure(self) -> "CssrsRussianForm": + if tuple(item.id for item in self.items) != ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"): + raise ValueError("Recent form requires seven ordered answer fields") + if tuple(option.value for option in self.options) != (1, 0): + raise ValueError("source answer columns are Yes, No") + return self + + +def load_cssrs_russian(path: Path | None = None) -> CssrsRussianForm: + path = path or get_settings().config_dir / "instruments" / "cssrs_recent_ru.yaml" + return CssrsRussianForm.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) diff --git a/config/instruments/cssrs_recent_ru.yaml b/config/instruments/cssrs_recent_ru.yaml new file mode 100644 index 0000000..872fcdf --- /dev/null +++ b/config/instruments/cssrs_recent_ru.yaml @@ -0,0 +1,61 @@ +# Verbatim source content; only whitespace/line wraps normalized. Not enabled in the legacy dialog. +id: cssrs_recent_ru +version: source-2026-10-03 +title: КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА +subtitle: Screen Version - Recent +period_label_original: Последние 1 месяца +initial_instruction: Задайте вопросы 1 и 2 +branch_instruction: ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6. +options: +- value: 1 + label: Да +- value: 0 + label: Нет +items: +- id: q1 + text: Возникало ли у Вас желание умереть или заснуть и не просыпаться? + interviewer_guidance: null + recall_period: past_month + yes_risk: low +- id: q2 + text: Думали ли Вы в самом деле о том, чтобы убить себя? + interviewer_guidance: null + recall_period: past_month + yes_risk: low +- id: q3 + text: Думали ли Вы о том, как Вы могли бы это сделать? + interviewer_guidance: 'Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения + он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал + конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства, + но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так + и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело + до конца”.' + recall_period: past_month + yes_risk: moderate +- id: q4 + text: Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними? + interviewer_guidance: 'Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать + в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно + не стану ничего предпринимать”.' + recall_period: past_month + yes_risk: high +- id: q5 + text: Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить + этот план? + interviewer_guidance: null + recall_period: past_month + yes_risk: high +- id: q6_lifetime + text: Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить + с собой? + interviewer_guidance: 'Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание + или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять + или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались + застрелиться, порезались, пытался повеситься и т. д.' + recall_period: lifetime + yes_risk: moderate +- id: q6_recent + text: ЕСЛИ ДА, то было ли это в последние 3 месяца? + interviewer_guidance: null + recall_period: past_3_months + yes_risk: high diff --git a/docs/clinical/README.md b/docs/clinical/README.md index 3c08e12..3afdd9a 100644 --- a/docs/clinical/README.md +++ b/docs/clinical/README.md @@ -60,30 +60,57 @@ RUS-AUDIT: © ВОЗ, 2021; [отчёт ВОЗ](https://www.who.int/europe/publi CC BY-NC-SA 3.0 IGO. Использование в этом пакете — некоммерческий прототип. Наличие шкалы здесь не означает одобрения продукта ВОЗ или авторами. -## C-SSRS: источник и незавершённая часть +## C-SSRS: русский бланк Recent Официальная [страница переводов Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/) ссылается на [Dropbox Screeners](https://www.dropbox.com/scl/fo/pkg1gwxxn1266rke8oite/AC2UFfOKCtefU-o9EEXP-yk?dl=0&rlkey=4grouuqbchfa8obxlcb3msnpz). -Ожидаемый файл: `C-SSRS Screener - Russian.pdf`. **Файл не получен, официальный -русский текст не добавлен**: проверка административной политики браузера не -доступна; обход блокировки не выполнялся. +3 октября пользователь предоставил локальный `C-SSRS Screener - Russian.pdf`. +Неизменённая копия — `cssrs-screener-russian.pdf`, одна страница, заголовок +**Screen Version - Recent**. Вопросы, пояснения, периоды, варианты и цветовые +категории сверены с отрисованной страницей. Хеши оригинала и извлечённого текста +сохранены в `review-status.json`. Файл получен от пользователя после блокировки +загрузки; совпадение байтов с удалённой копией Dropbox независимо не проверено. + +`config/instruments/cssrs_recent_ru.yaml` содержит отдельную транскрипцию, +`app/core/cssrs_content.py` — типизированный загрузчик `load_cssrs_russian`. +Версия `source-2026-10-03` — локальная версия содержания, **не номер редакции +Columbia**. Даты в метаданных PDF не используются как дата клинической редакции. +Сохранены все формулировки и опечатки, включая «Последние 1 месяца», +«ЕСЛИ ДА 2», «пытался повеситься» и повтор «запаслись таблетками». +Нормализованы только межсловные пробелы и переносы. Пояснения интервьюеру +хранятся отдельно от вопросов. Любое редактирование требует отдельного решения. + +Переходы по бланку: q1/q2 всем; при q2=0 перейти к q6; при q2=1 спросить q3–q5; +q6 за всю жизнь всем; при положительном q6 уточнить последние три месяца. +Для q1–q5 период — последний месяц. Ответы «Да»/«Нет» кодируются 1/0; +это коды ответов, **не суммируемые баллы**. + +| Положительный ответ | Цвет бланка | Категория | +|---|---|---| +| q1, q2 | жёлтый | low | +| q3, q6_lifetime | оранжевый | moderate | +| q4, q5, q6_recent | красный | high | + +При нескольких положительных ответах используется максимальная категория. +Поведение ранее трёх месяцев не превращается в «риска нет». +Правила совпадают с [Recent Screener для первичной помощи](https://cssrs.columbia.edu/wp-content/uploads/C-SSRS-Screener-with-Triage-Points-for-Primary-Care-2026.pdf) +и [описанием триажа Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/risk-identification/). +Неполные или противоречивые данные не получают итоговую категорию; +известный сигнал сохраняется как нижняя граница риска. `config/scales/cssrs_screen.yaml` остаётся историческим демо-переводом. Его q6 спрашивает только о последних трёх месяцах; автоматически конвертировать такой -ответ в пожизненный нельзя. Не выдавать этот файл за официальный перевод. +ответ в пожизненный нельзя. Новый бланк расположен вне `config/scales`, поскольку +действующий последовательный диалог не поддерживает его условные переходы. +Он ещё не включён в пациентский сценарий. Загрузчик текста сам по себе не +разрешает продолжение опроса: адаптер должен учитывать `evaluate_intake`, +а порядок обязательных вопросов получать из `score_cssrs_recent`. -Новая структура данных использует 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. -Статус лицензии и применимость к коммерческому продукту подтверждаются отдельно. +Бланк не разрешает противоречие об остановке при любом «Да»: C01 остаётся +решением врача. Также нужны C06 (возраст/популяция, формат применения, обучение +и повторные замеры) и согласование пациентского сценария. Версию «с последнего +контакта» нельзя получить простой заменой заголовка Recent. Статус лицензии и +применимость к коммерческому продукту подтверждаются отдельно. ## Независимый обратный перевод @@ -153,3 +180,5 @@ RUS-AUDIT-S для каждого пола, границы полной форм ветви C-SSRS, давнее поведение, пропуски/противоречия, кризис на неполном опросе, конфликт PHQ-9/9 и C-SSRS, пороги маршрутов, независимые направления и отсутствие разрешения на автоматический переход без реального решения врача. +Тесты C-SSRS дополнительно восстанавливают полный текст страницы из структуры, +проверяют хеши и сверяют все комбинации ветвей с цветовыми категориями бланка. diff --git a/docs/clinical/cssrs-screener-russian.pdf b/docs/clinical/cssrs-screener-russian.pdf new file mode 100644 index 0000000..dd81f46 Binary files /dev/null and b/docs/clinical/cssrs-screener-russian.pdf differ diff --git a/docs/clinical/cssrs-screener-russian.source.txt b/docs/clinical/cssrs-screener-russian.source.txt new file mode 100644 index 0000000..5a3e313 --- /dev/null +++ b/docs/clinical/cssrs-screener-russian.source.txt @@ -0,0 +1 @@ +КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА Screen Version - Recent Последние 1 месяца Задайте вопросы 1 и 2 Да Нет 1. Возникало ли у Вас желание умереть или заснуть и не просыпаться? 2. Думали ли Вы в самом деле о том, чтобы убить себя? ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6. 3. Думали ли Вы о том, как Вы могли бы это сделать? Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства, но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело до конца”. 4. Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними? Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно не стану ничего предпринимать”. 5. Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить этот план? 6. Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить с собой? Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались застрелиться, порезались, пытался повеситься и т. д. ЕСЛИ ДА, то было ли это в последние 3 месяца? Low Risk Moderate Risk High Risk diff --git a/docs/clinical/review-status.json b/docs/clinical/review-status.json index c711355..c102b56 100644 --- a/docs/clinical/review-status.json +++ b/docs/clinical/review-status.json @@ -6,11 +6,24 @@ "clinician_approval": null, "phone_checks": "not_performed", "cssrs_russian_pdf": { - "expected_filename": "C-SSRS Screener - Russian.pdf", - "downloaded": false, - "text_verified": false, + "original_filename": "C-SSRS Screener - Russian.pdf", + "received": true, + "acquisition": "user_provided_local_file", + "received_on": "2026-10-03", + "file": "cssrs-screener-russian.pdf", + "sha256": "d1dc77068b73e83a0e7959681b0fe9f15775a8b99863cf0e11794308a4c77c85", + "source_text": "cssrs-screener-russian.source.txt", + "source_text_sha256": "89835360bd59c6ead1a56bec6f40c46b72f81165366c92f85ba5d25291748de7", + "text_verified": true, + "visual_verification": true, + "pages": 1, + "form_subtitle": "Screen Version - Recent", + "content_version": "source-2026-10-03", + "publisher_version": null, "source": "https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/", - "blocker": "Browser admin-enforced security check unavailable; not bypassed" + "remote_bytes_independently_verified": false, + "clinical_use_approved": false, + "note": "User supplied the file after the official-source download workflow was blocked. Local content and colors checked; publisher version is not inferred from PDF metadata." }, "intake_release_ready": false, "policy_version": "mvp-review-2026-10-03" diff --git a/tests/test_cssrs_content.py b/tests/test_cssrs_content.py new file mode 100644 index 0000000..3993f82 --- /dev/null +++ b/tests/test_cssrs_content.py @@ -0,0 +1,58 @@ +"""Protect the reviewed PDF transcription and its connection to Recent scoring.""" + +import hashlib +import json +from itertools import product +from pathlib import Path + +from app.core.cssrs_content import load_cssrs_russian +from app.core.questionnaire import score_cssrs_recent + +CLINICAL = Path(__file__).resolve().parents[1] / "docs" / "clinical" + + +def test_source_hashes_and_complete_transcription(): + form = load_cssrs_russian() + manifest = json.loads((CLINICAL / "review-status.json").read_text())["cssrs_russian_pdf"] + assert hashlib.sha256((CLINICAL / manifest["file"]).read_bytes()).hexdigest() == manifest["sha256"] + source = (CLINICAL / manifest["source_text"]).read_bytes() + assert hashlib.sha256(source).hexdigest() == manifest["source_text_sha256"] + assert form.version == manifest["content_version"] + # Reconstruct the complete extracted page, including numbering, guidance, + # original typos, answer order and the color legend. No wording is dropped. + blocks = [form.title, form.subtitle, form.period_label_original, form.initial_instruction] + blocks.extend(option.label for option in form.options) + for index, item in enumerate(form.items): + blocks.append(f"{index + 1}. {item.text}" if index < 6 else item.text) + if item.interviewer_guidance: + blocks.append(item.interviewer_guidance) + if item.id == "q2": + blocks.append(form.branch_instruction) + blocks.extend(["Low Risk", "Moderate Risk", "High Risk"]) + assert " ".join(blocks) == source.decode().strip() + assert [item.recall_period for item in form.items] == ["past_month"] * 5 + ["lifetime", "past_3_months"] + + +def test_all_source_branches_and_color_categories_match_scorer(): + form = load_cssrs_russian() + colors = {item.id: item.yes_risk for item in form.items} + assert list(colors.values()) == ["low", "low", "moderate", "high", "high", "moderate", "high"] + levels = ["none", "low", "moderate", "high"] + for values in product((0, 1), repeat=7): + all_answers = dict(zip(colors, values, strict=True)) + asked = ["q1", "q2"] + if all_answers["q2"]: + asked.extend(["q3", "q4", "q5"]) + asked.append("q6_lifetime") + if all_answers["q6_lifetime"]: + asked.append("q6_recent") + answers = {} + for item_id in asked: + partial = score_cssrs_recent(answers) + assert not partial.complete + assert partial.missing[0] == item_id + answers[item_id] = all_answers[item_id] + result = score_cssrs_recent(answers) + expected = max((levels.index(colors[key]) for key, value in answers.items() if value), default=0) + assert result.complete and result.risk == levels[expected] + assert result.required == tuple(asked)