From 44814b72934e53ad529eaadf15c0361b862aefd2 Mon Sep 17 00:00:00 2001 From: Arkhip Date: Sun, 4 Oct 2026 16:21:16 +0300 Subject: [PATCH] fix(clinical): red flags, one crisis rule, callback after a crisis, one table of numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clinical review of the questionnaires (ADR-013): - Red-flag block first in stage 1 (config/instruments/red_flags.yaml); any «Да» stops the questionnaire with an acute alert and the help screen of its category. - One crisis rule: the crisis screen opens at a HIGH C-SSRS risk only; moderate stops and goes to a psychiatrist (24 h as a guide), low is a flag with the safety plan. PHQ-9 item 9 with a negative screener is a discrepancy flag; without a completed screener it asks the C-SSRS at once. config/questionnaire.yaml: cssrs_positive_policy graded (C01). - Every crisis leaves a callback task for the doctor on duty (post_crisis_call, one per patient) and the app asks how the person is the next day, the safety plan one tap away. - Routing: risk and flags first, scales second; PCL-5 >= 50 no longer means a priority appointment (pcl5_severe is moderate on the dashboard too). - Crisis numbers live only in config/texts/crisis.yaml; texts and the agent prompt refer to them via {phone:}. Fixes the children's helpline offered to adults by the chat agent. Refs #92 Co-Authored-By: Claude Opus 5.5 --- app/api/fixtures.py | 3 +- app/core/instrument_catalog.py | 5 +- app/core/intake_questions.py | 22 +++++++-- app/core/interpretation.py | 3 +- app/core/questionnaire.py | 11 +++-- app/core/safety.py | 19 ++++++++ app/dialog/flows/intake.py | 72 +++++++++++++++++++++++++--- app/dialog/texts.py | 4 +- app/llm/agent/prompts.py | 9 +++- app/scheduler/jobs.py | 13 ++++- app/services/followup.py | 43 +++++++++++++++++ app/services/monitoring.py | 2 + config/instruments/red_flags.yaml | 25 ++++++++++ config/questionnaire.yaml | 4 +- config/questionnaire_demo.yaml | 1 - config/rules.yaml | 8 ++-- config/texts/crisis.yaml | 7 +++ config/texts/dialog.yaml | 12 +++-- config/texts/results.yaml | 4 +- docs/DECISIONS.md | 11 +++++ docs/clinical/README.md | 2 +- docs/clinical/crisis-contacts.md | 3 ++ tests/test_api_patient_web.py | 6 +-- tests/test_crisis_numbers.py | 31 ++++++++++++ tests/test_dialog_engine.py | 4 +- tests/test_dialog_intake.py | 79 ++++++++++++++++++++++++++----- tests/test_questionnaire.py | 11 +++-- tests/test_rules.py | 4 +- tests/test_services_monitoring.py | 3 +- 29 files changed, 365 insertions(+), 56 deletions(-) create mode 100644 app/services/followup.py create mode 100644 config/instruments/red_flags.yaml create mode 100644 tests/test_crisis_numbers.py diff --git a/app/api/fixtures.py b/app/api/fixtures.py index 9be6541..9b9904c 100644 --- a/app/api/fixtures.py +++ b/app/api/fixtures.py @@ -16,6 +16,7 @@ from app.api.schemas import ( PatientListItem, SeriesPoint, ) +from app.core.safety import fill_phones from app.integrations.base import Prescription TZ = ZoneInfo("Europe/Moscow") @@ -262,7 +263,7 @@ def andrey_card() -> PatientCard: "Что помогает: прогулка с собакой, душ", "Люди и места: брат Олег, спортзал", "Кому звонить: жена, сослуживец Миша", - "Специалисты: д-р Смирнова, 8-800-2000-122, 112", + fill_phones("Специалисты: д-р Смирнова, {phone:helpline}, 112"), "Безопасная среда: ружьё хранится у брата", ], summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). " diff --git a/app/core/instrument_catalog.py b/app/core/instrument_catalog.py index 7b0a3b5..497ebeb 100644 --- a/app/core/instrument_catalog.py +++ b/app/core/instrument_catalog.py @@ -13,7 +13,7 @@ from pydantic import BaseModel from app.config import get_settings from app.core.cssrs_content import load_cssrs_russian -from app.core.intake_questions import DIARY_PARTS, content, diary_config, texts +from app.core.intake_questions import DIARY_PARTS, content, diary_config, red_flags, texts from app.core.scales import load_scale Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "scale"] @@ -92,6 +92,9 @@ def catalog() -> Catalog: ), ) + flags = red_flags() + add(FormInfo(id="red_flags", title="Вопросы о безопасности", section="safety"), [i["id"] for i in flags["items"]]) + cssrs = load_cssrs_russian() add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items]) diff --git a/app/core/intake_questions.py b/app/core/intake_questions.py index b332574..694ac68 100644 --- a/app/core/intake_questions.py +++ b/app/core/intake_questions.py @@ -53,11 +53,25 @@ def cssrs_answers(answers: dict) -> dict[str, int]: return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")} +def red_flags() -> dict: + return yaml.safe_load((get_settings().config_dir / "instruments/red_flags.yaml").read_text(encoding="utf-8")) + + +def red_flag_category(item_id: str) -> str | None: + return next((i["category"] for i in red_flags()["items"] if i["id"] == item_id), None) + + def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None: - """Stage 1 in the fixed order of «Опросники для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, - then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. - Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py), - so a positive answer interrupts the questionnaire wherever it comes.""" + """Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники + для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of + PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. Items inside a scale keep the order of the source. + The risk stop rules run after every answer (flows/intake.py), so a positive answer interrupts the questionnaire + wherever it comes.""" + flags = red_flags() + for n, item in enumerate(flags["items"]): + if item["id"] not in answers: + text = (flags["intro"] + "\n\n" if n == 0 else "") + item["text"] + return Question(item["id"], text, tuple((o["value"], o["label"]) for o in flags["options"]), "now") for item in ("phq9_1", "phq9_2"): if item not in answers: return scale_question("phq9", item) diff --git a/app/core/interpretation.py b/app/core/interpretation.py index aa46d65..5f99015 100644 --- a/app/core/interpretation.py +++ b/app/core/interpretation.py @@ -12,6 +12,7 @@ import yaml from app.config import get_settings from app.core.intake_questions import content from app.core.questionnaire import IntakeDecision, IntakePolicy +from app.core.safety import fill_phones # Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile). FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"} @@ -23,7 +24,7 @@ PCL5_IMPROVED_BELOW = 28 def texts() -> dict[str, Any]: - return yaml.safe_load((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8")) + return yaml.safe_load(fill_phones((get_settings().config_dir / "texts/results.yaml").read_text(encoding="utf-8"))) def render(interpretation: dict[str, Any]) -> str: diff --git a/app/core/questionnaire.py b/app/core/questionnaire.py index 09a3065..e12b8e1 100644 --- a/app/core/questionnaire.py +++ b/app/core/questionnaire.py @@ -50,7 +50,6 @@ class IntakePolicy(BaseModel): 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") @@ -65,8 +64,6 @@ class IntakePolicy(BaseModel): raise ValueError("choose a total threshold OR a zone for alcohol expansion") if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at: raise ValueError("PHQ-9 thresholds must be ordered") - if self.pcl5_specialist_at >= self.pcl5_priority_at: - raise ValueError("PCL-5 thresholds must be ordered") return self @@ -276,7 +273,9 @@ def evaluate_intake( if c.positive: flags.append("cssrs_positive") flags.extend(c.conflicts) - if item9_positive or c.risk_lower_bound == "high": + # One crisis rule (ADR-013): a high C-SSRS risk. Item 9 alone is a crisis only while the screener is not complete; + # with a negative / low screener it is the «discrepancy» flag of the routing table, not the crisis screen. + if c.risk_lower_bound == "high" or (item9_positive and not c.complete): 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) @@ -327,9 +326,11 @@ def evaluate_intake( # 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 + # Risk and flags first, scales second (clinical review 2026-10-04). A high PCL-5 alone is not a reason for a + # priority appointment: it goes to the planned specialist route. if c.risk == "moderate": primary = "urgent_psychiatrist" - elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at: + elif phq_total >= policy.phq9_priority_at: primary = "priority_psychiatrist" elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at: primary = "psychiatrist_or_psychotherapist" diff --git a/app/core/safety.py b/app/core/safety.py index 7c476c8..46a9644 100644 --- a/app/core/safety.py +++ b/app/core/safety.py @@ -395,6 +395,25 @@ def _crisis_cfg(path: Path = CRISIS_PATH) -> _CrisisCfg: return _CrisisCfg.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) +_PHONE_REF = re.compile(r"\{phone:([a-z_]+)\}") + + +def fill_phones(text: str, path: Path = CRISIS_PATH) -> str: + """Replace `{phone:}` with the number from the single table in config/texts/crisis.yaml. + + Every patient text and prompt refers to the table instead of repeating a number, so a number is changed in one + place. An unknown resource fails loudly: a wrong number on the crisis screen must never pass silently. + """ + resources = _crisis_cfg(path).resources + + def number(m: re.Match[str]) -> str: + if m.group(1) not in resources: + raise ValueError(f"unknown crisis resource {m.group(1)!r}") + return resources[m.group(1)].phone + + return _PHONE_REF.sub(number, text) + + def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str: lines = [body.strip(), ""] for r in resources: diff --git a/app/dialog/flows/intake.py b/app/dialog/flows/intake.py index 22b2905..e5479f3 100644 --- a/app/dialog/flows/intake.py +++ b/app/dialog/flows/intake.py @@ -14,6 +14,7 @@ from app.core.intake_questions import ( diary_questions, group_answers, next_intake, + red_flag_category, scale_question, texts, ) @@ -27,6 +28,7 @@ from app.dialog.contracts import Button, Event, Reply from app.dialog.crisis import crisis_open, open_crisis from app.dialog.fsm import Step, on_enter, state, trigger from app.services import questionnaire as service +from app.services.followup import schedule_post_crisis_call def allowed(ctx): @@ -105,6 +107,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None): level=AlertLevel.ACUTE, params={"record_id": row.id if row else None, "flags": flags}, ) + await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code) step = open_crisis(ctx, categories or [], source="questionnaire:" + code) step.data["questionnaire_record"] = None step.data["intake_review_required"] = True @@ -113,6 +116,41 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None): return step +async def item9_discrepancy(ctx, row): + profile = await ctx.profile() + if profile: + profile.enhanced_monitoring = True + await service.alert( + ctx.session, + ctx.token, + row.id + ":phq9_cssrs_discrepancy", + "phq9_cssrs_discrepancy", + ctx.now, + level=AlertLevel.HIGH, + params={"record_id": row.id}, + ) + row.details = {**row.details, "item9_flagged": True} + + +async def item9_assessment(ctx, row): + """Thoughts of death in PHQ-9 item 9 → the C-SSRS screener at once; the screener's level decides (graded).""" + await service.alert( + ctx.session, + ctx.token, + row.id + ":item9_assessment", + "phq9_item9_cssrs", + ctx.now, + level=AlertLevel.HIGH, + params={"record_id": row.id}, + ) + await service.finish(ctx, row, "complete", extra={"stop_reason": "phq9_item9_cssrs"}) + profile = await ctx.profile() + if profile: + profile.enhanced_monitoring = True + task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", row.id + ":cssrs", ctx.now) + return await start(ctx, "cssrs_since_contact", task) + + async def manual_review(ctx, row, code): await service.finish(ctx, row, "review_required", extra={"stop_reason": code}) await service.alert( @@ -135,9 +173,24 @@ async def advance(ctx, row): a = row.answers c = score_cssrs_recent(cssrs_answers(a)) policy = load_policy() - # PHQ-9 item 9 ≥1 — in the full scale or in the depression module of the diary — stops at once (stage 1 rule). - if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high": + # Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category. + for item_id, value in a.items(): + if item_id.startswith("rf_") and value == 1: + category = red_flag_category(item_id) + return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else []) + # One crisis rule for the whole app (ADR-013): the crisis screen opens at a HIGH C-SSRS risk only. Moderate → + # stop + psychiatrist (about 24 h); low → flag, safety plan, continue. + if c.risk_lower_bound == "high": return await urgent(ctx, row, code="suicide_signal") + if a.get("dphq_9", 0) > 0 and row.kind in DIARY_KINDS: + # Diary depression module, item 9 ≥1 → the C-SSRS right away («Этап 2»); its risk level decides. + return await item9_assessment(ctx, row) + if a.get("phq9_9", 0) > 0 and not c.complete: + # Item 9 without a completed screener (only in a scheduled PHQ-9): assess the risk right away. + return await item9_assessment(ctx, row) + if a.get("phq9_9", 0) > 0 and not row.details.get("item9_flagged"): + # Item 9 ≥1 with a negative / low C-SSRS = discrepancy (routing table): flag to the doctor, safety plan. + await item9_discrepancy(ctx, row) if c.positive and policy.cssrs_positive_policy == "stop_any": return await urgent(ctx, row, code="cssrs_positive") if c.conflicts: @@ -165,7 +218,7 @@ async def advance(ctx, row): return await manual_review(ctx, row, "answer_conflict") if q: replies = [question_reply(row, q)] - if c.complete and c.risk == "low" and not row.details.get("plan_shown"): + if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"): from app.dialog.actions import safety_plan_reply replies.insert(0, await safety_plan_reply(ctx)) @@ -225,11 +278,12 @@ async def advance(ctx, row): if row.kind in ("phq9", "pcl5"): sid = row.kind total = extra["totals"][sid]["total"] - priority = getattr(policy, sid + "_priority_at") + # Only PHQ-9 has a priority threshold; PCL-5 alone never makes an appointment a priority. + priority = getattr(policy, sid + "_priority_at", None) specialist = getattr(policy, sid + "_specialist_at") extra["route"] = ( "priority_psychiatrist" - if total >= priority + if priority is not None and total >= priority else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician") ) earlier = [ @@ -284,7 +338,13 @@ async def advance(ctx, row): return step from app.dialog.flows.core import menu_reply - return Step(replies=[menu_reply(ctx, shown)], next_state="idle", data={"questionnaire_record": None}) + replies = [menu_reply(ctx, shown)] + if ((c.complete and c.risk == "low") or row.details.get("item9_flagged")) and not row.details.get("plan_shown"): + # The safety plan is never skipped because the risk signal came with the last question. + from app.dialog.actions import safety_plan_reply + + replies.insert(0, await safety_plan_reply(ctx)) + return Step(replies=replies, next_state="idle", data={"questionnaire_record": None}) # Diary flags that bring a full scale forward («Этап 2», p.4). diff --git a/app/dialog/texts.py b/app/dialog/texts.py index 2c987b3..eaa42b7 100644 --- a/app/dialog/texts.py +++ b/app/dialog/texts.py @@ -7,6 +7,7 @@ import yaml from pydantic import BaseModel, ConfigDict, Field from app.config import get_settings +from app.core.safety import fill_phones class _Strict(BaseModel): @@ -91,6 +92,7 @@ class ReachOutTexts(_Strict): silence: str missed_doses: str default: str + after_crisis: str class VisitTexts(_Strict): @@ -120,4 +122,4 @@ def dialog_texts_path() -> Path: @lru_cache def load_dialog_texts(path: Path | None = None) -> DialogTexts: path = path or dialog_texts_path() - return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + return DialogTexts.model_validate(yaml.safe_load(fill_phones(path.read_text(encoding="utf-8")))) diff --git a/app/llm/agent/prompts.py b/app/llm/agent/prompts.py index e7a8e27..05545f6 100644 --- a/app/llm/agent/prompts.py +++ b/app/llm/agent/prompts.py @@ -1,6 +1,8 @@ """System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools.""" -SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у +from app.core.safety import fill_phones + +_SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах. Как работать: @@ -32,4 +34,7 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков. Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон -доверия 8-800-2000-122, и что врач будет уведомлён.""" +доверия {phone:helpline}, и что врач будет уведомлён.""" + +# Numbers come from the single table config/texts/crisis.yaml. +SYSTEM_PROMPT = fill_phones(_SYSTEM_PROMPT) diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index e6f50e8..91d1700 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -22,11 +22,13 @@ from app.db import repo from app.db.clinical import PatientProfile from app.db.intake import QuestionnaireTask from app.db.session import get_clinical_session -from app.dialog.contracts import Event, Reply, get_engine +from app.dialog.contracts import Button, Event, Reply, get_engine +from app.dialog.texts import load_dialog_texts from app.integrations.base import integrations from app.scheduler import demo from app.services import monitoring from app.services import questionnaire as service +from app.services.followup import next_day_check_due log = logging.getLogger(__name__) _worker = None @@ -43,6 +45,15 @@ async def plan(): state = await repo.get_dialog_state(session, profile.token) if not state or state.data.get("consent") is not True or state.data.get("adult") is not True: continue + crisis = state.data.get("crisis") + if next_day_check_due(crisis, now): + # The day after a crisis the app asks how the person is, the safety plan one tap away. + texts = load_dialog_texts() + plan_button = Button(label=texts.safety_plan.title.rstrip(":"), data="safety_plan") + await integrations.outbox.push( + profile.token, [Reply(text=texts.reach_out.after_crisis, buttons=[[plan_button]], kind="question")] + ) + state.data = {**state.data, "crisis": {**crisis, "followup_sent": now.isoformat()}} rows = await service.records(session, profile.token) initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) if initial is None: diff --git a/app/services/followup.py b/app/services/followup.py new file mode 100644 index 0000000..a0e58b9 --- /dev/null +++ b/app/services/followup.py @@ -0,0 +1,43 @@ +"""Follow-up after a crisis (clinical review 2026-10-04: «план безопасности и звонок после кризиса — обязательно»). + +Every crisis — from the safety lexicon, a rule (PHQ-9 item 9, C-SSRS high) or the questionnaire (red flags, high +risk) — leaves a task for the doctor on duty: call the patient back, about 24 hours as a guide. The task is an alert +on the dashboard; «позвонили» is its acknowledgement (`repo.ack_alert`, audited). One open task per patient: a +repeated crisis signal does not pile up callbacks. On the next day the app itself asks how the person is, with the +safety plan at hand (`app/scheduler/jobs.py`). +""" + +from datetime import datetime, timedelta + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.types import AlertLevel +from app.db import repo +from app.db.clinical import Alert + +POST_CRISIS_CALL = "post_crisis_call" +ON_DUTY = "on-duty" +CALL_WITHIN = timedelta(hours=24) # a guide, not a guarantee +NEXT_DAY_CHECK_AFTER = timedelta(hours=20) + + +async def schedule_post_crisis_call(session: AsyncSession, token: str, now: datetime, *, source: str) -> Alert | None: + if POST_CRISIS_CALL in await repo.open_alert_codes(session, token): + return None + return await repo.add_alert( + session, + token=token, + level=AlertLevel.HIGH, + code=POST_CRISIS_CALL, + reason="Перезвонить пациенту после кризиса — дежурный специалист, ориентир 24 ч. Отметка «позвонили» — " + "подтверждение этого сигнала.", + ts=now, + params={"due_at": (now + CALL_WITHIN).isoformat(), "assignee": ON_DUTY, "source": source}, + ) + + +def next_day_check_due(crisis: dict | None, now: datetime) -> bool: + """The day after a crisis the app asks how the person is — once per crisis episode.""" + if not crisis or not crisis.get("opened_at") or crisis.get("followup_sent"): + return False + return now - datetime.fromisoformat(crisis["opened_at"]) >= NEXT_DAY_CHECK_AFTER diff --git a/app/services/monitoring.py b/app/services/monitoring.py index 4b0e3d8..34d82b6 100644 --- a/app/services/monitoring.py +++ b/app/services/monitoring.py @@ -30,6 +30,7 @@ from app.db.audit import audit from app.db.session import get_clinical_session from app.integrations.base import integrations from app.services.contracts import register_monitoring +from app.services.followup import schedule_post_crisis_call from app.services.snapshot import build_snapshot log = logging.getLogger(__name__) @@ -93,6 +94,7 @@ class RuleMonitoring: ts=now, ) notify.append((ON_DUTY_DOCTOR, level, action.reason_ru)) + await schedule_post_crisis_call(s, token, now, source=f"rule:{action.code}") await refresh_status(s, token) log.info("monitoring token=%s actions=%s", token, [a.code for a in actions]) for doctor_id, level, text in notify: diff --git a/config/instruments/red_flags.yaml b/config/instruments/red_flags.yaml new file mode 100644 index 0000000..66fa0be --- /dev/null +++ b/config/instruments/red_flags.yaml @@ -0,0 +1,25 @@ +# Red flags — asked first in stage 1, before any scale (clinical review 2026-10-04: «обязательно»). +# Any «Да» stops the questionnaire: an acute alert to the doctor, a callback task for the doctor on duty and the +# help screen (crisis script variant by `category`, config/texts/crisis.yaml). Wording — team draft, the doctor +# approves it (#8). These are not a scale: there is no score, only a stop rule. +version: red-flags-v1-2026-10-04 +intro: "Сначала несколько коротких вопросов о безопасности. Отвечайте так, как есть сейчас." +options: +- {value: 1, label: Да} +- {value: 0, label: Нет} +items: +- id: rf_psychosis + text: Слышите ли Вы сейчас голоса или видите то, чего не видят и не слышат другие люди? + category: null +- id: rf_harm_others + text: Есть ли у Вас сейчас мысли причинить вред другому человеку? + category: harm_others +- id: rf_weapons + text: Есть ли у Вас дома или рядом доступ к оружию? + category: null +- id: rf_violence + text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие? + category: domestic_violence +- id: rf_intoxication + text: Находитесь ли Вы сейчас в состоянии сильного опьянения или тяжёлой отмены алкоголя либо других веществ? + category: null diff --git a/config/questionnaire.yaml b/config/questionnaire.yaml index 4f5937f..39c67a4 100644 --- a/config/questionnaire.yaml +++ b/config/questionnaire.yaml @@ -1,7 +1,8 @@ # Review candidate from the supplied MVP PDF, not a signed clinical protocol. version: mvp-v2-review-2026-10-03 approval: null -cssrs_positive_policy: pending +# C01 decided by the team after the clinical review (2026-10-04, ADR-013): crisis screen at HIGH risk only. +cssrs_positive_policy: graded rus_audit_s_expand_from_zone: null phq2_expand_at: 2 tsq_expand_at: 6 @@ -9,5 +10,4 @@ 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/questionnaire_demo.yaml b/config/questionnaire_demo.yaml index 77c4f50..b556efd 100644 --- a/config/questionnaire_demo.yaml +++ b/config/questionnaire_demo.yaml @@ -17,5 +17,4 @@ 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/rules.yaml b/config/rules.yaml index 1f19944..a8cce9a 100644 --- a/config/rules.yaml +++ b/config/rules.yaml @@ -62,7 +62,7 @@ rules: params: {} actions: - {type: alert, level: high} - reason_ru: "C-SSRS: умеренный риск, врачу в течение 24 ч" + reason_ru: "C-SSRS: умеренный риск, психиатр — ориентир 24 ч" # ---------------------------------------------------------------- scale thresholds (yellow) - id: phq9_severe @@ -73,10 +73,12 @@ rules: reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})" - id: pcl5_severe - description: Latest PCL-5 total >= 50 → doctor within 24 h. + description: > + Latest PCL-5 total >= 50 → planned specialist review. Not a priority appointment by itself: risk and flags + come first, scales second (clinical review 2026-10-04). params: {threshold: 50} actions: - - {type: alert, level: high} + - {type: alert, level: moderate} reason_ru: "PCL-5 = {total} (порог {threshold})" - id: phq9_rise diff --git a/config/texts/crisis.yaml b/config/texts/crisis.yaml index eccbd41..c70b884 100644 --- a/config/texts/crisis.yaml +++ b/config/texts/crisis.yaml @@ -1,4 +1,6 @@ # Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается. +# `resources` — ЕДИНСТВЕННАЯ таблица телефонов приложения: остальные тексты и промпты ссылаются на неё через +# `{phone:<ключ>}` (app/core/safety.py::fill_phones). Источники и проверка — docs/clinical/crisis-contacts.md. # СТАТУС: черновик, утверждает психиатр (#8). # # Выбор варианта (app/core/safety.py::crisis_reply): `.<категория>` по порядку category_priority, @@ -17,6 +19,11 @@ resources: name: "Психологическая помощь МЧС России" phone: "+7 (495) 989-50-50" note: "круглосуточно; стоимость звонка по тарифу оператора" + # For minors only (age gate stop text); not a general adult crisis line (docs/clinical/crisis-contacts.md). + child_helpline: + name: "Детский телефон доверия" + phone: "8-800-2000-122" + note: "для детей, подростков и родителей; бесплатно, анонимно" police: name: "Полиция" phone: "102" diff --git a/config/texts/dialog.yaml b/config/texts/dialog.yaml index 19690c9..60ebdad 100644 --- a/config/texts/dialog.yaml +++ b/config/texts/dialog.yaml @@ -53,7 +53,7 @@ age_gate: unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990." minor_stop: |- Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы. - Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу. + Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия {phone:child_helpline} (бесплатно, анонимно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу. Если есть угроза жизни прямо сейчас — звоните 112. idle: @@ -79,9 +79,9 @@ revoked: crisis: # Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы. - repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:" + repeat: "Я рядом. Если вам угрожает опасность прямо сейчас — позвоните:" # Любое другое сообщение, пока кризис открыт (до подтверждения врача). - hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50." + hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}." # Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса. closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент." @@ -101,7 +101,7 @@ safety_plan: - "Что помогает мне самому: прогулка, душ, дыхание, любимое дело." - "Люди и места, которые отвлекают: к кому можно пойти." - "Кому позвонить, если тяжело: близкий человек, сослуживец." - - "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112." + - "Специалисты: ваш врач, телефон доверия {phone:helpline}, экстренные службы 112." - "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку." means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя." @@ -109,10 +109,12 @@ reach_out: silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке." missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу." default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно." + # The day after a crisis (app/services/followup.py); the doctor on duty also calls back. + after_crisis: "Как вы сегодня? Вчера было очень тяжело — мне важно знать, что вы в порядке. Ваш план безопасности под кнопкой ниже. С вами также свяжется дежурный специалист. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}." visit: button: "Записаться к врачу раньше" unavailable: |- Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже. - Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50. + Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}. diff --git a/config/texts/results.yaml b/config/texts/results.yaml index 0165968..806e7df 100644 --- a/config/texts/results.yaml +++ b/config/texts/results.yaml @@ -32,13 +32,13 @@ findings: text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом." suicide_low: title: "Тяжёлые мысли" - text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия." + text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}." tbi: title: "Контузия или ЧМТ" text: "Рекомендуем консультацию невролога." routes: - urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра." + urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов." priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни." psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту." psychologist: "Рекомендуем консультацию психолога." diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6ec03da..10c792c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3,6 +3,17 @@ > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. +## 2026-10-04 — ADR-013: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров +**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия). +**Решение:** +1. Красные флаги (`config/instruments/red_flags.yaml`) идут первыми в этапе 1: голоса/видения, мысли причинить вред другому, доступ к оружию, насилие дома, сильное опьянение или отмена. Любое «Да» останавливает опрос: острый алерт врачу и экран помощи нужного варианта (`harm_others`, `domestic_violence`). +2. Одно правило кризиса во всём приложении: кризисный экран открывается только при **высоком** риске C-SSRS. Умеренный риск — стоп опроса и психиатр, «ориентир 24 ч»; низкий — флаг врачу, план безопасности, опрос продолжается. Пункт 9 PHQ-9 при отрицательном C-SSRS — флаг «расхождение» и план безопасности; без пройденного C-SSRS (плановый PHQ-9, дневник) — сразу C-SSRS, решает его уровень. `config/questionnaire.yaml`: `cssrs_positive_policy: graded` (C01). +3. После любого кризиса дежурному создаётся задача «перезвонить пациенту» (алерт `post_crisis_call`, ориентир 24 ч; «позвонили» — подтверждение алерта), одна на пациента, пока открыта. На следующий день приложение само спрашивает «как вы» с кнопкой плана безопасности (`app/services/followup.py`). +4. Маршрут: риск и флаги → шкалы. PCL-5 ≥50 убран из приоритетной записи (и в правилах дашборда `pcl5_severe` — уровень moderate): высокий PCL-5 ведёт к плановой записи к специалисту. +5. Все телефоны — только в `config/texts/crisis.yaml`; остальные тексты и промпт агента ссылаются через `{phone:<ключ>}`, неизвестный ключ роняет загрузку, тест не пускает «голые» номера в код и тексты. +**Причина:** врач назвал пункты 1–2 обязательными; одно правило кризиса исключает расхождение демо с документом; неверный номер на кризисном экране недопустим. +**Последствия:** формулировки красных флагов — черновик команды, утверждает врач (#8). Старое правило дашборда `phq9_item9` (ADR-006, сценарий без опросника) по-прежнему поднимает кризис по п.9 — свести при следующем ревью правил. + ## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату **Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста». **Решение:** этап 1 общий для всех и идёт в фиксированном порядке документа: PHQ-2 → фильтр травмы/TSQ → RUS-AUDIT-S → C-SSRS → общие вопросы, затем этап 2 только там, где этап 1 положительный. Пункты внутри шкал не переставляются. Стоп-правила риска срабатывают после каждого ответа. После этапа 1 правила (`app/core/interpretation.py`, тексты в `config/texts/results.yaml`) показывают пациенту предварительный результат: «не выявлено», «предположение депрессии» (PHQ-9 ≥10), «склонность к ПТСР» (PCL-5 ≥31), алкоголь, ЧМТ, маршрут. Это всегда «результат скрининга, не диагноз». Результат включает модули дневника: `depression` и `veteran` (ПТСР). Без врача их определяет скрининг, модули врача сохраняются и дополняются. Дневник этапа 2 состоит из двух частей: утро (часы сна, качество сна, порции алкоголя) и вечер (настроение, лекарства + модули: 3 пункта PHQ-9 в день по кругу, короткая PCL-5 из 4 пунктов). C-SSRS «с последнего контакта» задаётся каждый вечер только при суицидальном риске. Флаги считаются по недельным средним против личной нормы за первые 14 дней и выносят полную шкалу раньше срока. Каждая запись хранит интерпретацию, показанную при завершении; журнал отдаёт `/me/{token}/results`. Демо (`INTAKE_DEMO=1` + демо-часы) читает отдельный `config/questionnaire_demo.yaml`, помеченный «не клиническое согласование», а реальная политика не меняется. diff --git a/docs/clinical/README.md b/docs/clinical/README.md index 5b5d591..f0c72f6 100644 --- a/docs/clinical/README.md +++ b/docs/clinical/README.md @@ -160,7 +160,7 @@ q6 за всю жизнь всем; при положительном q6 уто | ID | Решение, которое нужно зафиксировать | Статус | |---|---|---| -| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending | +| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | **решено 2026-10-04 (ADR-013):** кризисный экран только при высоком риске; умеренный — стоп и психиатр (ориентир 24 ч); низкий — флаг, план безопасности, продолжение. `cssrs_positive_policy: graded`. Подпись врача — в #8. | | C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending | | C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending | | C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending | diff --git a/docs/clinical/crisis-contacts.md b/docs/clinical/crisis-contacts.md index 4a9309a..c5f134f 100644 --- a/docs/clinical/crisis-contacts.md +++ b/docs/clinical/crisis-contacts.md @@ -1,5 +1,8 @@ # Проверка контактов: 3 октября 2026 +> **Единая таблица номеров приложения — `config/texts/crisis.yaml` (`resources`).** Остальные тексты и промпт +> чат-агента ссылаются на неё через `{phone:<ключ>}` (ADR-013); номер меняется в одном месте. + Проверены официальные страницы, **не телефонное соединение**. Никаких звонков или уведомлений службам/врачу не выполнялось. diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index e8f5ab5..cd6be56 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -169,8 +169,8 @@ async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeyp await press() await press("consent:yes") buttons = await press("age:adult") - # Stage 1 starts with PHQ-2, in the order of the MVP questionnaire. - assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons) + # Stage 1 starts with the red-flag block, then the order of the MVP questionnaire. + assert any(b.startswith("iq:") and ":rf_psychosis:" in b for b in buttons) assert "intake:help" in buttons and "intake:pause" in buttons q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"] assert q["enabled"] and q["intake"] == "active" @@ -214,7 +214,7 @@ async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"] await press() await press("consent:yes") - assert any(":phq9_1:" in b for b in await press("age:adult")) + assert any(":rf_psychosis:" in b for b in await press("age:adult")) async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None: diff --git a/tests/test_crisis_numbers.py b/tests/test_crisis_numbers.py new file mode 100644 index 0000000..7b011b5 --- /dev/null +++ b/tests/test_crisis_numbers.py @@ -0,0 +1,31 @@ +"""Crisis numbers live in one table (config/texts/crisis.yaml); every other text refers to it by `{phone:}`.""" + +import re +from pathlib import Path + +import pytest + +from app.core.safety import CRISIS_PATH, fill_phones +from app.llm.agent.prompts import SYSTEM_PROMPT + +ROOT = Path(__file__).resolve().parents[1] +# Any phone number except the universal short emergency numbers 112 / 101–103. +NUMBER = re.compile(r"(\+7|\b8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}") + + +def test_no_number_outside_the_crisis_table(): + texts = [p for p in (ROOT / "config" / "texts").glob("*.yaml") if p != CRISIS_PATH] + code = [*(ROOT / "app").rglob("*.py"), *(ROOT / "web" / "src").rglob("*.ts*")] + offenders = [ + str(p.relative_to(ROOT)) + for p in [*texts, *code] + if p.name != "privacy.py" # the PII scrubber documents the phone format with an example + and NUMBER.search("\n".join(line for line in p.read_text(encoding="utf-8").splitlines() if "#" not in line)) + ] + assert offenders == [] + + +def test_references_resolve_and_unknown_keys_fail(): + assert "989-50-50" in fill_phones("{phone:helpline}") and "989-50-50" in SYSTEM_PROMPT + with pytest.raises(ValueError): + fill_phones("{phone:no_such_line}") diff --git a/tests/test_dialog_engine.py b/tests/test_dialog_engine.py index 8063bdc..13beb44 100644 --- a/tests/test_dialog_engine.py +++ b/tests/test_dialog_engine.py @@ -482,4 +482,6 @@ def test_dialog_texts_avoid_forbidden_words() -> None: body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#")) for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"): assert word not in body - assert "152-фз" in body and "8-800-2000-122" in body + assert "152-фз" in body + # Numbers live in one table (config/texts/crisis.yaml); the minors' stop text gets the children's line from it. + assert "8-800-2000-122" not in body and "8-800-2000-122" in load_dialog_texts().age_gate.minor_stop diff --git a/tests/test_dialog_intake.py b/tests/test_dialog_intake.py index 09c1bc0..114d1cb 100644 --- a/tests/test_dialog_intake.py +++ b/tests/test_dialog_intake.py @@ -13,6 +13,7 @@ from app.core import clock from app.core.scales import clear_cache from app.db import repo, session from app.db.bootstrap import init_db +from app.db.clinical import Alert from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask from app.dialog.contracts import Event from app.dialog.engine import Engine @@ -76,7 +77,8 @@ def answer_button(replies, choices=None): for b in line: if b.data.startswith("iq:"): item = b.data.split(":")[2] - index = choices.get(item, 1 if item.startswith("cssrs_") else 0) + # «Нет» by default for the safety items (C-SSRS, red flags), the first option otherwise. + index = choices.get(item, 1 if item.startswith(("cssrs_", "rf_")) else 0) for line2 in reply.buttons: for other in line2: if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"): @@ -122,17 +124,17 @@ async def test_pause_restart_and_duplicate_callbacks(released): old = answer_button(replies) await asyncio.gather(press(released, old), press(released, old)) (row,) = await rows() - assert row.answers == {"phq9_1": 0} + assert row.answers == {"rf_psychosis": 0} await press(released, "intake:pause") restarted = Engine(gateway=LLMGateway(MockTasks())) replies = await press(restarted, "intake:resume") - assert "phq9_2" in answer_button(replies) + assert "rf_harm_others" in answer_button(replies) await press(restarted, old) - assert (await rows())[0].answers == {"phq9_1": 0} + assert (await rows())[0].answers == {"rf_psychosis": 0} assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) -@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"]) +@pytest.mark.parametrize("signal", ["help", "red_flag", "cssrs"]) async def test_immediate_stop_preserves_partial_answers(released, signal): replies = await press(released, "intake:start") if signal == "help": @@ -142,13 +144,45 @@ async def test_immediate_stop_preserves_partial_answers(released, signal): for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): replies = await press(released, answer_button(replies, {item: 0})) else: - replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1}) + replies = await press(released, answer_button(replies, {"rf_psychosis": 0})) assert any(r.kind == "crisis" for r in replies) (row,) = await rows() assert row.status == "interrupted" and row.completed_at is None notices = await rows(QuestionnaireNotice) assert len(notices) == 1 and notices[0].status == "test_sink" assert "уведомлён" not in replies[0].text + # Every crisis leaves a callback task for the doctor on duty (one per patient while it is open). + calls = [a for a in await rows(Alert) if a.code == "post_crisis_call"] + assert len(calls) == 1 and calls[0].params["assignee"] == "on-duty" + + +async def test_red_flag_violence_uses_its_crisis_variant(released): + replies = await press(released, "intake:start") + for _ in range(3): # psychosis, harm to others, weapons: «Нет» + replies = await press(released, answer_button(replies)) + replies = await press(released, answer_button(replies, {"rf_violence": 0})) + crisis = next(r for r in replies if r.kind == "crisis") + assert "насили" in crisis.text.lower() + (row,) = await rows() + assert row.details["stop_reason"] == "red_flag_violence" + + +async def test_item9_with_a_negative_screener_is_a_flag_not_a_crisis(released): + # One crisis rule (ADR-013): C-SSRS was negative, so PHQ-9 item 9 is a discrepancy for the doctor. + choices = {f"phq9_{i}": 2 for i in range(1, 9)} + choices.update(trauma=1, tbi=1, psychotropics=1, phq9_9=1) + replies = await press(released, "intake:start") + for _ in range(60): + button = answer_button(replies, choices) + if button is None or any(r.kind == "safety_plan" for r in replies): + break + replies = await press(released, button) + assert not any(r.kind == "crisis" for r in replies) + assert any(r.kind == "safety_plan" for r in replies) + codes = {a.code for a in await rows(Alert)} + assert "phq9_cssrs_discrepancy" in codes and "post_crisis_call" not in codes + (row,) = await rows() + assert row.status == "complete" and "phq9_cssrs_discrepancy" in row.details["decision"]["flags"] async def complete_existing(e, replies, choices): @@ -350,16 +384,24 @@ async def test_diary_morning_then_evening_with_the_ptsd_module(released): assert any("уже заполнен" in r.text for r in replies) -async def test_depression_module_item9_stops_with_crisis(released, monkeypatch): +async def test_depression_module_item9_asks_the_screener_at_once(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) async with session.get_clinical_session() as s: await repo.upsert_profile(s, token=TOKEN, modules=["depression"]) monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) await run_diary(released, await press(released, "menu:checkin")) - replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1}) - assert any(r.kind == "crisis" for r in replies) + replies = await press(released, "menu:checkin") + for _ in range(10): + button = answer_button(replies, {"medication": 2, "dphq_9": 1}) + replies = await press(released, button) + if ":cssrs_" in (answer_button(replies) or ""): + break + # Item 9 → the C-SSRS right away («Этап 2»): no crisis screen before the screener says the risk is high. + assert not any(r.kind == "crisis" for r in replies) and any("последнего ответа" in r.text for r in replies) evening = next(r for r in await rows() if r.kind == "diary_evening") - assert evening.answers["dphq_9"] == 1 and evening.status == "interrupted" + assert evening.answers["dphq_9"] == 1 and evening.details["stop_reason"] == "phq9_item9_cssrs" + replies = await complete_existing(released, replies, {"cssrs_q1": 0, "cssrs_q2": 0, "cssrs_q3": 0, "cssrs_q4": 0}) + assert any(r.kind == "crisis" for r in replies) async def test_unknown_sex_goes_to_review_without_a_zone(released): @@ -380,7 +422,7 @@ async def test_version_change_blocks_resume_and_preserves_answers(released): path.write_text(yaml.safe_dump(data, allow_unicode=True)) await press(released, "intake:resume") (row,) = await rows() - assert row.status == "review_required" and row.answers == {"phq9_1": 0} + assert row.status == "review_required" and row.answers == {"rf_psychosis": 0} async def test_followup_delta_uses_comparable_complete_baseline(released): @@ -434,6 +476,8 @@ async def test_diary_schedule_uses_moscow_date(released, monkeypatch): async def test_resumed_answer_keeps_original_period(released, monkeypatch): replies = await press(released, "intake:start") + for _ in range(5): # the red-flag block + replies = await press(released, answer_button(replies)) await press(released, answer_button(replies)) await press(released, "intake:pause") monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1)) @@ -472,3 +516,16 @@ async def test_without_a_doctor_the_screening_defines_the_modules(released): assert any("не выявлено" in r.text for r in replies) async with session.get_clinical_session() as s: assert (await repo.get_profile(s, TOKEN)).modules == [] + + +async def test_next_day_after_a_crisis_the_app_asks_once(released, monkeypatch): + replies = await press(released, "intake:start") + await press(released, answer_button(replies, {"rf_psychosis": 0})) # red flag → crisis + outbox = MemoryOutbox() + monkeypatch.setattr(integrations, "outbox", outbox) + monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(hours=21)) + await jobs.plan() + await jobs.plan() + sent = outbox.drain(TOKEN) + assert len(sent) == 1 and "Как вы сегодня" in sent[0].text + assert any(b.data == "safety_plan" for line in sent[0].buttons for b in line) diff --git a/tests/test_questionnaire.py b/tests/test_questionnaire.py index 77bd2ec..4af8f75 100644 --- a/tests/test_questionnaire.py +++ b/tests/test_questionnaire.py @@ -172,12 +172,16 @@ def test_unapproved_policy_never_continues_even_a_negative_screen(): 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(phq9={"phq9_9": 1}, cssrs={}), # item 9 before the screener is complete dict(cssrs={"q4": 1}, alcohol={"bad": 50}), ]: assert intake(policy=load_policy(), **overrides).status == "crisis" + + +def test_item9_with_a_negative_screener_is_a_discrepancy_flag_not_a_crisis(): + # One crisis rule (ADR-013): the crisis screen opens at a high C-SSRS risk only. r = intake(phq9={"phq9_9": 1}) - assert "phq9_cssrs_discrepancy" in r.flags + assert r.status != "crisis" and {"phq9_item9_positive", "phq9_cssrs_discrepancy"} <= set(r.flags) def test_physician_must_choose_between_stop_and_graded(): @@ -208,7 +212,8 @@ def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative(): (30, "self_help"), (31, "psychiatrist_or_psychotherapist"), (49, "psychiatrist_or_psychotherapist"), - (50, "priority_psychiatrist"), + (50, "psychiatrist_or_psychotherapist"), + (80, "psychiatrist_or_psychotherapist"), ], ) def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route): diff --git a/tests/test_rules.py b/tests/test_rules.py index 7e58f8a..b219b76 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -155,7 +155,9 @@ def test_severe_totals(scale: str, total: int, code: str, fires: bool) -> None: acts = evaluate(s) assert (code in codes(acts)) is fires if fires: - assert next(a for a in acts if a.code == code).level == AlertLevel.HIGH + # PCL-5 alone is a planned specialist review, not a priority (clinical review 2026-10-04). + expected = AlertLevel.MODERATE if scale == "pcl5" else AlertLevel.HIGH + assert next(a for a in acts if a.code == code).level == expected @pytest.mark.parametrize( diff --git a/tests/test_services_monitoring.py b/tests/test_services_monitoring.py index 31627d6..27bd254 100644 --- a/tests/test_services_monitoring.py +++ b/tests/test_services_monitoring.py @@ -178,6 +178,7 @@ async def test_andrey_13_days(db: MemoryNotifier) -> None: "sleep_lt4_3n", "alcohol_3d", "phq9_item9", # same evaluation: YAML order + "post_crisis_call", # the crisis leaves a callback task for the doctor on duty "phq9_rise", ] assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system" @@ -260,7 +261,7 @@ async def test_notifier_failure_keeps_alert(db: MemoryNotifier, monkeypatch: pyt await scale("pcl5", 55) await monitoring.RuleMonitoring().after_data_change(TOKEN) async with session.get_clinical_session() as s: - assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "high")] + assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "moderate")] assert await color() == "yellow"