release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
29 changed files with 365 additions and 56 deletions
Showing only changes of commit 44814b7293 - Show all commits
+2 -1
View File
@@ -16,6 +16,7 @@ from app.api.schemas import (
PatientListItem, PatientListItem,
SeriesPoint, SeriesPoint,
) )
from app.core.safety import fill_phones
from app.integrations.base import Prescription from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow") 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-й день (снят после звонка врача). " 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.config import get_settings
from app.core.cssrs_content import load_cssrs_russian 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 from app.core.scales import load_scale
Section = Literal["safety", "mood", "trauma", "alcohol", "about", "diary", "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() cssrs = load_cssrs_russian()
add(FormInfo(id="cssrs", title=cssrs.title, section="safety"), ["cssrs_" + i.id for i in cssrs.items]) 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_")} 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: 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, """Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
then the context questions; stage 2 (rest of PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
Items inside a scale keep the order of the source. The risk stop rules run after every answer (flows/intake.py), PHQ-9, PCL-5, full RUS-AUDIT) only where stage 1 is positive. Items inside a scale keep the order of the source.
so a positive answer interrupts the questionnaire wherever it comes.""" 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"): for item in ("phq9_1", "phq9_2"):
if item not in answers: if item not in answers:
return scale_question("phq9", item) return scale_question("phq9", item)
+2 -1
View File
@@ -12,6 +12,7 @@ import yaml
from app.config import get_settings from app.config import get_settings
from app.core.intake_questions import content from app.core.intake_questions import content
from app.core.questionnaire import IntakeDecision, IntakePolicy 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). # Stage 1 finding → diary module of stage 2 (PTSD is the «veteran» module of the profile).
FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"} FINDING_MODULE = {"depression_suspected": "depression", "ptsd_suspected": "veteran"}
@@ -23,7 +24,7 @@ PCL5_IMPROVED_BELOW = 28
def texts() -> dict[str, Any]: 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: 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_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_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_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) pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after") @model_validator(mode="after")
@@ -65,8 +64,6 @@ class IntakePolicy(BaseModel):
raise ValueError("choose a total threshold OR a zone for alcohol expansion") 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: if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered") 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 return self
@@ -276,7 +273,9 @@ def evaluate_intake(
if c.positive: if c.positive:
flags.append("cssrs_positive") flags.append("cssrs_positive")
flags.extend(c.conflicts) 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) return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any": if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing) 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-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0 phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.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": if c.risk == "moderate":
primary = "urgent_psychiatrist" 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" primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at: elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist" 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"))) 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: def _render(body: str, resources: list[CrisisResource], doctor_notified: str, grounding: str) -> str:
lines = [body.strip(), ""] lines = [body.strip(), ""]
for r in resources: for r in resources:
+66 -6
View File
@@ -14,6 +14,7 @@ from app.core.intake_questions import (
diary_questions, diary_questions,
group_answers, group_answers,
next_intake, next_intake,
red_flag_category,
scale_question, scale_question,
texts, 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.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service from app.services import questionnaire as service
from app.services.followup import schedule_post_crisis_call
def allowed(ctx): def allowed(ctx):
@@ -105,6 +107,7 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
level=AlertLevel.ACUTE, level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags}, 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 = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True step.data["intake_review_required"] = True
@@ -113,6 +116,41 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
return step 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): async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code}) await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert( await service.alert(
@@ -135,9 +173,24 @@ async def advance(ctx, row):
a = row.answers a = row.answers
c = score_cssrs_recent(cssrs_answers(a)) c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy() 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). # Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
if a.get("phq9_9", 0) > 0 or a.get("dphq_9", 0) > 0 or c.risk_lower_bound == "high": 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") 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": if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive") return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts: if c.conflicts:
@@ -165,7 +218,7 @@ async def advance(ctx, row):
return await manual_review(ctx, row, "answer_conflict") return await manual_review(ctx, row, "answer_conflict")
if q: if q:
replies = [question_reply(row, 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 from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx)) replies.insert(0, await safety_plan_reply(ctx))
@@ -225,11 +278,12 @@ async def advance(ctx, row):
if row.kind in ("phq9", "pcl5"): if row.kind in ("phq9", "pcl5"):
sid = row.kind sid = row.kind
total = extra["totals"][sid]["total"] 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") specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = ( extra["route"] = (
"priority_psychiatrist" "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") else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
) )
earlier = [ earlier = [
@@ -284,7 +338,13 @@ async def advance(ctx, row):
return step return step
from app.dialog.flows.core import menu_reply 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). # 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 pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings from app.config import get_settings
from app.core.safety import fill_phones
class _Strict(BaseModel): class _Strict(BaseModel):
@@ -91,6 +92,7 @@ class ReachOutTexts(_Strict):
silence: str silence: str
missed_doses: str missed_doses: str
default: str default: str
after_crisis: str
class VisitTexts(_Strict): class VisitTexts(_Strict):
@@ -120,4 +122,4 @@ def dialog_texts_path() -> Path:
@lru_cache @lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts: def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path() 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 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-заголовков. Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 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.clinical import PatientProfile
from app.db.intake import QuestionnaireTask from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session 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.integrations.base import integrations
from app.scheduler import demo from app.scheduler import demo
from app.services import monitoring from app.services import monitoring
from app.services import questionnaire as service from app.services import questionnaire as service
from app.services.followup import next_day_check_due
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_worker = None _worker = None
@@ -43,6 +45,15 @@ async def plan():
state = await repo.get_dialog_state(session, profile.token) 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: if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue 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) rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None) initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is 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.db.session import get_clinical_session
from app.integrations.base import integrations from app.integrations.base import integrations
from app.services.contracts import register_monitoring from app.services.contracts import register_monitoring
from app.services.followup import schedule_post_crisis_call
from app.services.snapshot import build_snapshot from app.services.snapshot import build_snapshot
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -93,6 +94,7 @@ class RuleMonitoring:
ts=now, ts=now,
) )
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru)) 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) await refresh_status(s, token)
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions]) log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
for doctor_id, level, text in notify: 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. # Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03 version: mvp-v2-review-2026-10-03
approval: null 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 rus_audit_s_expand_from_zone: null
phq2_expand_at: 2 phq2_expand_at: 2
tsq_expand_at: 6 tsq_expand_at: 6
@@ -9,5 +10,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10 phq9_specialist_at: 10
phq9_priority_at: 20 phq9_priority_at: 20
pcl5_specialist_at: 31 pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3 pcl5_behavior_flag_at: 3
-1
View File
@@ -17,5 +17,4 @@ phq9_psychologist_at: 5
phq9_specialist_at: 10 phq9_specialist_at: 10
phq9_priority_at: 20 phq9_priority_at: 20
pcl5_specialist_at: 31 pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3 pcl5_behavior_flag_at: 3
+5 -3
View File
@@ -62,7 +62,7 @@ rules:
params: {} params: {}
actions: actions:
- {type: alert, level: high} - {type: alert, level: high}
reason_ru: "C-SSRS: умеренный риск, врачу в течение 24 ч" reason_ru: "C-SSRS: умеренный риск, психиатр — ориентир 24 ч"
# ---------------------------------------------------------------- scale thresholds (yellow) # ---------------------------------------------------------------- scale thresholds (yellow)
- id: phq9_severe - id: phq9_severe
@@ -73,10 +73,12 @@ rules:
reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})" reason_ru: "PHQ-9 = {total} (тяжёлая депрессия, порог {threshold})"
- id: pcl5_severe - 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} params: {threshold: 50}
actions: actions:
- {type: alert, level: high} - {type: alert, level: moderate}
reason_ru: "PCL-5 = {total} (порог {threshold})" reason_ru: "PCL-5 = {total} (порог {threshold})"
- id: phq9_rise - id: phq9_rise
+7
View File
@@ -1,4 +1,6 @@
# Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается. # Кризисный скрипт (guardrail №2, issue #4). Отправляется ДОСЛОВНО при safety = acute; LLM не вызывается.
# `resources` — ЕДИНСТВЕННАЯ таблица телефонов приложения: остальные тексты и промпты ссылаются на неё через
# `{phone:<ключ>}` (app/core/safety.py::fill_phones). Источники и проверка — docs/clinical/crisis-contacts.md.
# СТАТУС: черновик, утверждает психиатр (#8). # СТАТУС: черновик, утверждает психиатр (#8).
# #
# Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority, # Выбор варианта (app/core/safety.py::crisis_reply): `<audience>.<категория>` по порядку category_priority,
@@ -17,6 +19,11 @@ resources:
name: "Психологическая помощь МЧС России" name: "Психологическая помощь МЧС России"
phone: "+7 (495) 989-50-50" phone: "+7 (495) 989-50-50"
note: "круглосуточно; стоимость звонка по тарифу оператора" 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: police:
name: "Полиция" name: "Полиция"
phone: "102" phone: "102"
+7 -5
View File
@@ -53,7 +53,7 @@ age_gate:
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990." unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |- minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы. Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу. Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия {phone:child_helpline} (бесплатно, анонимно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112. Если есть угроза жизни прямо сейчас — звоните 112.
idle: idle:
@@ -79,9 +79,9 @@ revoked:
crisis: crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы. # Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:" repeat: "Я рядом. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача). # Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50." hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса. # Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент." closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
@@ -101,7 +101,7 @@ safety_plan:
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело." - "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти." - "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец." - "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112." - "Специалисты: ваш врач, телефон доверия {phone:helpline}, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку." - "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя." means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
@@ -109,10 +109,12 @@ reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке." silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу." missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно." default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
# The day after a crisis (app/services/followup.py); the doctor on duty also calls back.
after_crisis: "Как вы сегодня? Вчера было очень тяжело — мне важно знать, что вы в порядке. Ваш план безопасности под кнопкой ниже. С вами также свяжется дежурный специалист. Если станет хуже — звоните 112 или на телефон доверия {phone:helpline}."
visit: visit:
button: "Записаться к врачу раньше" button: "Записаться к врачу раньше"
unavailable: |- unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже. Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50. Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
+2 -2
View File
@@ -32,13 +32,13 @@ findings:
text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом." text: "Рекомендуем консультацию нарколога — параллельно с основным маршрутом."
suicide_low: suicide_low:
title: "Тяжёлые мысли" title: "Тяжёлые мысли"
text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия." text: "Вы отметили мысли о смерти. Мы добавили план безопасности и будем каждый день коротко спрашивать о самочувствии. Если станет тяжело — звоните 112 или на телефон доверия {phone:helpline}."
tbi: tbi:
title: "Контузия или ЧМТ" title: "Контузия или ЧМТ"
text: "Рекомендуем консультацию невролога." text: "Рекомендуем консультацию невролога."
routes: routes:
urgent_psychiatrist: "Срочно свяжитесь с психиатром — сегодня или завтра." urgent_psychiatrist: "Срочно свяжитесь с психиатром — ориентир: в течение 24 часов."
priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни." priority_psychiatrist: "Рекомендуем приоритетную запись к психиатру в ближайшие дни."
psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту." psychiatrist_or_psychotherapist: "Рекомендуем плановую запись к психиатру или психотерапевту."
psychologist: "Рекомендуем консультацию психолога." psychologist: "Рекомендуем консультацию психолога."
+11
View File
@@ -3,6 +3,17 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. > При конфликте с [`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: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату ## 2026-10-04 — ADR-012: Два этапа опросов — общий скрининг, предварительный результат пациенту, дневник по результату
**Контекст:** PDF «Опросники для MVP» (этап 1) и «Этап 2: наблюдение после диагноза». Пациент должен видеть, что у него всё в порядке или есть признаки депрессии/ПТСР, и дальше вести дневник под свой результат. Клиническая политика (`config/questionnaire.yaml`) закрыта до решений врача C01–C09, поэтому в демо опрос всегда заканчивался остановкой «нужна оценка специалиста». **Контекст:** 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 общий для всех и идёт в фиксированном порядке документа: 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 | Решение, которое нужно зафиксировать | Статус | | 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 | | C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending | | C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | 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 # Проверка контактов: 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()
await press("consent:yes") await press("consent:yes")
buttons = await press("age:adult") buttons = await press("age:adult")
# Stage 1 starts with PHQ-2, in the order of the MVP questionnaire. # Stage 1 starts with the red-flag block, then the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":phq9_1:" in b for b in buttons) assert any(b.startswith("iq:") and ":rf_psychosis:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"] q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active" 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"] assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press() await press()
await press("consent:yes") 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: 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("#")) body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#"))
for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"): for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"):
assert word not in body 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.core.scales import clear_cache
from app.db import repo, session from app.db import repo, session
from app.db.bootstrap import init_db from app.db.bootstrap import init_db
from app.db.clinical import Alert
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event from app.dialog.contracts import Event
from app.dialog.engine import Engine from app.dialog.engine import Engine
@@ -76,7 +77,8 @@ def answer_button(replies, choices=None):
for b in line: for b in line:
if b.data.startswith("iq:"): if b.data.startswith("iq:"):
item = b.data.split(":")[2] 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 line2 in reply.buttons:
for other in line2: for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"): 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) old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old)) await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows() (row,) = await rows()
assert row.answers == {"phq9_1": 0} assert row.answers == {"rf_psychosis": 0}
await press(released, "intake:pause") await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks())) restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume") 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) 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) 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): async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start") replies = await press(released, "intake:start")
if signal == "help": 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"): for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0})) replies = await press(released, answer_button(replies, {item: 0}))
else: 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) assert any(r.kind == "crisis" for r in replies)
(row,) = await rows() (row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice) notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink" assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text 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): 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) 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}) await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"]) await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await run_diary(released, await press(released, "menu:checkin")) await run_diary(released, await press(released, "menu:checkin"))
replies = await run_diary(released, await press(released, "menu:checkin"), {"medication": 2, "dphq_9": 1}) replies = await press(released, "menu:checkin")
assert any(r.kind == "crisis" for r in replies) 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") 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): 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)) path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume") await press(released, "intake:resume")
(row,) = await rows() (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): 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): async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start") 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, answer_button(replies))
await press(released, "intake:pause") await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1)) 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) assert any("не выявлено" in r.text for r in replies)
async with session.get_clinical_session() as s: async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).modules == [] 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(): def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
for overrides in [ for overrides in [
dict(help_now=True, cssrs={"broken": "data"}), 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}), dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
]: ]:
assert intake(policy=load_policy(), **overrides).status == "crisis" 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}) 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(): 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"), (30, "self_help"),
(31, "psychiatrist_or_psychotherapist"), (31, "psychiatrist_or_psychotherapist"),
(49, "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): 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) acts = evaluate(s)
assert (code in codes(acts)) is fires assert (code in codes(acts)) is fires
if 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( @pytest.mark.parametrize(
+2 -1
View File
@@ -178,6 +178,7 @@ async def test_andrey_13_days(db: MemoryNotifier) -> None:
"sleep_lt4_3n", "sleep_lt4_3n",
"alcohol_3d", "alcohol_3d",
"phq9_item9", # same evaluation: YAML order "phq9_item9", # same evaluation: YAML order
"post_crisis_call", # the crisis leaves a callback task for the doctor on duty
"phq9_rise", "phq9_rise",
] ]
assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system" 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 scale("pcl5", 55)
await monitoring.RuleMonitoring().after_data_change(TOKEN) await monitoring.RuleMonitoring().after_data_change(TOKEN)
async with session.get_clinical_session() as s: 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" assert await color() == "yellow"