feat: crisis message after the test and non-blocking; weapon answer triggers nothing; ЕМИАС demo = Никита
CI / ruff · pytest (pull_request) Successful in 2m33s

- CRISIS_BLOCKING (default off for the demo): a crisis no longer locks the dialog; the message and the
  doctor's acute alert still happen. The clinical protocol (blocking, immediate stop) stays behind the flag and
  keeps its tests.
- Crisis signals inside a questionnaire (intake red flags, high C-SSRS, PHQ-9 item 9) no longer stop it: the
  test is finished and the crisis message comes at the end; the doctor's alert is raised at once/at the end.
- Weapon access: asked and stored, triggers nothing (rule disabled, no red flag, no safety block).
- ЕМИАС demo persona is «Никита В.»; the one-click demo stays «Андрей С.».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 23:30:05 +03:00
co-authored by Claude Opus 5.5
parent 967ae2d5b6
commit 531bef1e73
16 changed files with 142 additions and 16 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ DOCTOR_ID = "doc-smirnova"
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
DEMO_PERSONAS: list[DemoPersona] = [
DemoPersona(token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
# ЕМИАС demo sign-in shows this persona as «Никита В.» (the one-click demo on the welcome screen is «Андрей С.»).
DemoPersona(token="tok-andrey", display_name="Никита В.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"),
]
+4 -2
View File
@@ -158,6 +158,7 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
DEMO_SOURCE = "tok-andrey"
ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита)
@router.post("/auth/demo/fresh", response_model=AuthOut)
@@ -172,12 +173,13 @@ async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut:
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
now = clock.now()
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
name = persona.display_name if not start_in_chat else ONE_CLICK_NAME
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
async with get_identity_session() as s:
await identity_repo.create_patient(
s,
token=token,
display_name=f"{persona.display_name} (демо)",
display_name=f"{name} (демо)",
snils=snils,
clinic_id="demo",
doctor_id=fixtures.DOCTOR_ID,
@@ -194,7 +196,7 @@ async def _fresh_demo_copy(*, start_in_chat: bool) -> AuthOut:
consent = {"consent": True, "consent_ver": "demo", "consent_at": now.isoformat(), "revoked_at": None}
await repo.set_dialog_state(s, token=token, state="idle", data={**consent, "adult": True}, now=now)
log.info("web auth: demo fresh tok=%s", token[:14])
return AuthOut(token=token, mode="emias", display_name=persona.display_name)
return AuthOut(token=token, mode="emias", display_name=name)
@router.post("/auth/anonymous", response_model=AuthOut)
+3
View File
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
demo_clock: bool = True
# Initial screening (onboarding questionnaires) on/off — the default; the demo console can flip it at runtime.
screening_enabled: bool = True
# Crisis blocks the dialog until the doctor's ack (clinical protocol) — off for the demo: the crisis message
# and the doctor's alert still happen, the patient keeps using everything.
crisis_blocking: bool = False
tz: str = "Europe/Moscow"
api_base_url: str = "http://localhost:8000"
+5
View File
@@ -61,6 +61,11 @@ 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 red_flag_triggers(item_id: str) -> bool:
"""False for questions that are asked and stored but never stop/alert (rf_weapons, team decision 2026-10-04)."""
return next((i.get("trigger", True) for i in red_flags()["items"] if i["id"] == item_id), True)
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
"""Stage 1: the red-flag block first (config/instruments/red_flags.yaml), then the fixed order of «Опросники
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
+4
View File
@@ -14,6 +14,7 @@ Decisions:
from typing import Any
from app.config import get_settings
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
from app.dialog.contracts import Reply
from app.dialog.fsm import Ctx, Step
@@ -37,6 +38,9 @@ def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
return Step(replies=[Reply(text=text, kind="crisis")])
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
if not get_settings().crisis_blocking:
# Non-blocking (demo default): the script is shown, the doctor is alerted, the patient keeps going.
return Step(replies=[Reply(text=script.text, kind="crisis")], data={"crisis": crisis})
return Step(
replies=[Reply(text=script.text, kind="crisis")],
next_state="crisis",
+21
View File
@@ -37,6 +37,7 @@ from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub
@@ -68,6 +69,9 @@ from app.services.contracts import MonitoringService, get_monitoring
log = logging.getLogger(__name__)
LAST_PROMPT = "last_prompt"
DEFERRED_CRISIS = "deferred_crisis"
QUESTIONNAIRE_PREFIXES = ("scale.", "veteran.onboarding.", "intake.")
DEFERRABLE_KINDS = frozenset({"crisis", "safety_plan"})
MEDICINE_WORDS = re.compile(
r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I
)
@@ -145,6 +149,8 @@ class Engine:
await self._apply(ctx, await self._start_pending(ctx), run)
self._defer_crisis_messages(ctx, event, run)
if _consented(ctx):
for reply in run.replies:
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
@@ -177,6 +183,21 @@ class Engine:
)
return run.replies
def _defer_crisis_messages(self, ctx: Ctx, event: Event, run: _Run) -> None:
"""Crisis messages triggered by test answers wait until the questionnaire is over (team decision
2026-10-04: let the patient finish the test, then show the crisis message; it blocks nothing). The doctor's
alert is not delayed — only the patient-facing message. A typed acute message is never deferred."""
if get_settings().crisis_blocking:
return # clinical protocol: the crisis takes over immediately
if event.type == "button" and ctx.state.startswith(QUESTIONNAIRE_PREFIXES):
held = [r for r in run.replies if r.kind in DEFERRABLE_KINDS]
if held:
run.replies[:] = [r for r in run.replies if r.kind not in DEFERRABLE_KINDS]
ctx.data[DEFERRED_CRISIS] = [*(ctx.data.get(DEFERRED_CRISIS) or []), *(r.model_dump() for r in held)]
return
if ctx.data.get(DEFERRED_CRISIS) and not ctx.state.startswith(QUESTIONNAIRE_PREFIXES):
run.replies.extend(Reply.model_validate(r) for r in ctx.data.pop(DEFERRED_CRISIS))
def _abandoned(self, ctx: Ctx) -> bool:
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
+47 -3
View File
@@ -4,6 +4,7 @@ import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.config import get_settings
from app.core import interpretation
from app.core.intake_questions import (
DIARY_KINDS,
@@ -15,6 +16,7 @@ from app.core.intake_questions import (
group_answers,
next_intake,
red_flag_category,
red_flag_triggers,
scale_question,
texts,
)
@@ -116,6 +118,39 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
return step
def _defer_urgent(row, code, categories=None):
"""Remember the first crisis signal of this questionnaire; the questionnaire goes on (team decision
2026-10-04: the crisis message comes at the end and blocks nothing)."""
if not row.details.get("pending_urgent"):
row.details = {**row.details, "pending_urgent": {"code": code, "categories": categories or []}}
async def _flush_urgent(ctx, row, step):
"""Questionnaire over: acute alert + callback for the doctor, then the (non-blocking) crisis message."""
pending = (row.details or {}).get("pending_urgent")
if not pending or step.next_state == "intake.collect":
return step
row.details = {**row.details, "pending_urgent": None}
code, categories = pending["code"], pending.get("categories") or []
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
f"{row.id}:{code}",
code,
ctx.now,
level=AlertLevel.ACUTE,
params={"record_id": row.id, "deferred": True},
)
await schedule_post_crisis_call(ctx.session, ctx.token, ctx.now, source="questionnaire:" + code)
crisis = open_crisis(ctx, categories, source="questionnaire:" + code)
for reply in crisis.replies:
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
return step.then(crisis)
async def item9_discrepancy(ctx, row):
profile = await ctx.profile()
if profile:
@@ -170,18 +205,27 @@ async def manual_review(ctx, row, code):
async def advance(ctx, row):
return await _flush_urgent(ctx, row, await _advance(ctx, row))
async def _advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
for item_id, value in a.items():
if item_id.startswith("rf_") and value == 1:
if item_id.startswith("rf_") and value == 1 and red_flag_triggers(item_id):
category = red_flag_category(item_id)
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
if get_settings().crisis_blocking: # clinical protocol: stop at once
return await urgent(ctx, row, code="red_flag_" + item_id[3:], categories=[category] if category else [])
_defer_urgent(row, "red_flag_" + item_id[3:], [category] if category else [])
break
# One crisis rule for the whole app (ADR-013): the crisis screen opens at a HIGH C-SSRS risk only. Moderate →
# stop + psychiatrist (about 24 h); low → flag, safety plan, continue.
if c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if get_settings().crisis_blocking:
return await urgent(ctx, row, code="suicide_signal")
_defer_urgent(row, "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)
+6 -2
View File
@@ -33,6 +33,7 @@ from dataclasses import replace
from datetime import timedelta
from typing import Any
from app.config import get_settings
from app.core.privacy import scrub
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
@@ -237,7 +238,10 @@ async def _record(
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
if crisis_flags or session["idx"] + 1 >= len(scale.items):
# Clinical protocol (crisis_blocking): a crisis flag ends the scale at once. Demo default: the scale is finished
# first and the crisis message comes at the end of the questionnaire (engine defers it), blocking nothing.
stop_now = bool(crisis_flags) and get_settings().crisis_blocking
if stop_now or session["idx"] + 1 >= len(scale.items):
return await _finish(ctx, scale, session, answers, result, crisis_flags)
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
@@ -278,7 +282,7 @@ async def _finish(
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
risk_screen = ctx.texts.settings.risk_screen_scale
if crisis_open(ctx.data):
crisis = Step(next_state="crisis")
crisis = Step(next_state="crisis") if get_settings().crisis_blocking else Step()
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)