Merge pull request 'fix(clinical): красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров' (#94) from fix/clinical-review-safety into dev
CI / ruff · pytest (push) Successful in 2m26s
CI / ruff · pytest (push) Successful in 2m26s
This commit was merged in pull request #94.
This commit is contained in:
+2
-1
@@ -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-й день (снят после звонка врача). "
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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"))))
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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: "Рекомендуем консультацию психолога."
|
||||||
|
|||||||
@@ -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`, помеченный «не клиническое согласование», а реальная политика не меняется.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Проверка контактов: 3 октября 2026
|
# Проверка контактов: 3 октября 2026
|
||||||
|
|
||||||
|
> **Единая таблица номеров приложения — `config/texts/crisis.yaml` (`resources`).** Остальные тексты и промпт
|
||||||
|
> чат-агента ссылаются на неё через `{phone:<ключ>}` (ADR-013); номер меняется в одном месте.
|
||||||
|
|
||||||
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
|
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
|
||||||
или уведомлений службам/врачу не выполнялось.
|
или уведомлений службам/врачу не выполнялось.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user