Merge pull request 'fix(clinical): красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров' (#94) from fix/clinical-review-safety into dev
CI / ruff · pytest (push) Successful in 2m26s

This commit was merged in pull request #94.
This commit is contained in:
2026-10-04 16:28:28 +03:00
29 changed files with 365 additions and 56 deletions
+2 -1
View File
@@ -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-й день (снят после звонка врача). "
+4 -1
View File
@@ -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])
+18 -4
View File
@@ -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)
+2 -1
View File
@@ -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:
+6 -5
View File
@@ -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"
+19
View File
@@ -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:<resource>}` 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:
+66 -6
View File
@@ -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).
+3 -1
View File
@@ -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"))))
+7 -2
View File
@@ -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)
+12 -1
View File
@@ -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:
+43
View File
@@ -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
+2
View File
@@ -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:
+25
View File
@@ -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
+2 -2
View File
@@ -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
-1
View File
@@ -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
+5 -3
View File
@@ -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
+7
View File
@@ -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): `<audience>.<категория>` по порядку 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"
+7 -5
View File
@@ -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}.
+2 -2
View File
@@ -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: "Рекомендуем консультацию психолога."
+11
View File
@@ -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`, помеченный «не клиническое согласование», а реальная политика не меняется.
+1 -1
View File
@@ -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 |
+3
View File
@@ -1,5 +1,8 @@
# Проверка контактов: 3 октября 2026
> **Единая таблица номеров приложения — `config/texts/crisis.yaml` (`resources`).** Остальные тексты и промпт
> чат-агента ссылаются на неё через `{phone:<ключ>}` (ADR-013); номер меняется в одном месте.
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось.
+3 -3
View File
@@ -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:
+31
View File
@@ -0,0 +1,31 @@
"""Crisis numbers live in one table (config/texts/crisis.yaml); every other text refers to it by `{phone:<key>}`."""
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}")
+3 -1
View File
@@ -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
+68 -11
View File
@@ -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)
+8 -3
View File
@@ -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):
+3 -1
View File
@@ -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(
+2 -1
View File
@@ -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"