diff --git a/app/api/fixtures.py b/app/api/fixtures.py index 9b9904c..eae1c58 100644 --- a/app/api/fixtures.py +++ b/app/api/fixtures.py @@ -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"), ] diff --git a/app/api/patient_web.py b/app/api/patient_web.py index daa3231..d5907ef 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -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) diff --git a/app/config.py b/app/config.py index 40b62bf..9f7426a 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/core/intake_questions.py b/app/core/intake_questions.py index 694ac68..c4913c6 100644 --- a/app/core/intake_questions.py +++ b/app/core/intake_questions.py @@ -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 diff --git a/app/dialog/crisis.py b/app/dialog/crisis.py index 9eaa4bd..e82b32f 100644 --- a/app/dialog/crisis.py +++ b/app/dialog/crisis.py @@ -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", diff --git a/app/dialog/engine.py b/app/dialog/engine.py index 2f7d43e..724022a 100644 --- a/app/dialog/engine.py +++ b/app/dialog/engine.py @@ -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): diff --git a/app/dialog/flows/intake.py b/app/dialog/flows/intake.py index e5479f3..04acede 100644 --- a/app/dialog/flows/intake.py +++ b/app/dialog/flows/intake.py @@ -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) diff --git a/app/dialog/scale_session.py b/app/dialog/scale_session.py index 48c3657..3111ebf 100644 --- a/app/dialog/scale_session.py +++ b/app/dialog/scale_session.py @@ -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) diff --git a/config/instruments/red_flags.yaml b/config/instruments/red_flags.yaml index 66fa0be..d6002f5 100644 --- a/config/instruments/red_flags.yaml +++ b/config/instruments/red_flags.yaml @@ -17,6 +17,8 @@ items: - id: rf_weapons text: Есть ли у Вас дома или рядом доступ к оружию? category: null + # Asked and stored, but NOT a stop rule (team decision 2026-10-04: weapon ownership is common in Russia). + trigger: false - id: rf_violence text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие? category: domestic_violence diff --git a/config/rules.yaml b/config/rules.yaml index a8cce9a..5357834 100644 --- a/config/rules.yaml +++ b/config/rules.yaml @@ -47,6 +47,7 @@ rules: reason_ru: "C-SSRS: высокий риск (намерение/план или поведение)" - id: weapon_access_risk + enabled: false # team decision 2026-10-04: weapon ownership is common in Russia — the answer triggers nothing description: > Access to weapons together with any suicide-risk signal (recent PHQ-9 item 9 >= 1 or C-SSRS >= moderate). Means restriction is one of the most evidence-based suicide-prevention measures → crisis safety block. diff --git a/config/texts/veteran_onboarding.yaml b/config/texts/veteran_onboarding.yaml index 09eeee3..30d033d 100644 --- a/config/texts/veteran_onboarding.yaml +++ b/config/texts/veteran_onboarding.yaml @@ -14,7 +14,8 @@ status: "черновик, утверждает психиатр (#8)" settings: # Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия. # Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию. - weapon_risk_flags: [phq9_item9_positive, cssrs_high, cssrs_moderate, auditc_positive] + # The weapon question exists but affects nothing (team decision 2026-10-04): no safety block, no trigger. + weapon_risk_flags: [] intro: text: |- diff --git a/tests/conftest.py b/tests/conftest.py index 2bf9991..b3e47c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,8 @@ def _no_live_llm(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("CHAT_AGENT", "off") monkeypatch.setenv("OPENROUTER_API_KEY", "") monkeypatch.setenv("LLM_PROVIDER", "mock") + # Tests cover the clinical protocol by default (blocking crisis); demo-mode tests flip it explicitly. + monkeypatch.setenv("CRISIS_BLOCKING", "true") get_settings.cache_clear() yield get_settings.cache_clear() diff --git a/tests/test_demo_crisis_deferred.py b/tests/test_demo_crisis_deferred.py new file mode 100644 index 0000000..a8f2587 --- /dev/null +++ b/tests/test_demo_crisis_deferred.py @@ -0,0 +1,36 @@ +"""Demo mode (crisis_blocking off): a crisis signal inside a questionnaire does not stop it; the crisis message +comes at the end and blocks nothing; the weapon question triggers nothing.""" + +import pytest + +from app.db.clinical import Alert +from tests.test_dialog_intake import answer_button, complete_existing, press, released, rows # noqa: F401 + + +@pytest.fixture +def non_blocking(monkeypatch): + from app.config import get_settings + + monkeypatch.setattr(get_settings(), "crisis_blocking", False) + + +async def test_red_flag_does_not_stop_the_questionnaire(released, non_blocking): # noqa: F811 + replies = await press(released, "intake:start") + replies = await press(released, answer_button(replies, {"rf_psychosis": 0})) # «Да» on a red flag + assert not any(r.kind == "crisis" for r in replies), "no crisis window in the middle of the test" + assert any(r.buttons for r in replies), "the next question is asked" + (row,) = await rows() + assert row.status != "interrupted" and row.details["pending_urgent"]["code"] == "red_flag_psychosis" + replies = await complete_existing(released, replies, {}) + assert any(r.kind == "crisis" for r in replies), "the crisis message comes at the end" + assert any(a.code == "red_flag_psychosis" and a.level == "acute" for a in await rows(Alert)) + + +async def test_weapon_answer_triggers_nothing(released, non_blocking): # noqa: F811 + replies = await press(released, "intake:start") + for _ in range(2): # psychosis, harm to others: «Нет» + replies = await press(released, answer_button(replies)) + replies = await press(released, answer_button(replies, {"rf_weapons": 0})) # «Да» on weapons + assert not any(r.kind == "crisis" for r in replies) + (row,) = await rows() + assert not row.details.get("pending_urgent") diff --git a/tests/test_dialog_veteran_onboarding.py b/tests/test_dialog_veteran_onboarding.py index a181158..55c4d4c 100644 --- a/tests/test_dialog_veteran_onboarding.py +++ b/tests/test_dialog_veteran_onboarding.py @@ -268,7 +268,7 @@ async def test_auditc_sex_declined_uses_default_threshold(dbs_nosex: None, engin assert (await results())["auditc"].severity == "positive" # unknown sex → sensitive threshold ≥ 3 # Weapon at home + AUDIT-C positive = risk signal → safe-storage block. r = await press(engine, "vonb:weapon:self") - assert VT.weapon.safety_block_self in [x.text for x in r] + assert VT.weapon.safety_block_self not in [x.text for x in r] # the weapon answer affects nothing # ----------------------------------------------------------------------------------------------- weapon / safety diff --git a/tests/test_rules.py b/tests/test_rules.py index b219b76..f513896 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -125,8 +125,9 @@ def test_cssrs_levels() -> None: @pytest.mark.parametrize( ("weapon", "cssrs", "item9", "fires"), [ - (True, "moderate", None, True), - (True, None, 1, True), + # Team decision 2026-10-04: weapon access triggers nothing (rule disabled in config/rules.yaml). + (True, "moderate", None, False), + (True, None, 1, False), (True, "low", 0, False), (False, "high", 2, False), (None, "moderate", None, False), diff --git a/tests/test_services_monitoring.py b/tests/test_services_monitoring.py index 27bd254..123a78a 100644 --- a/tests/test_services_monitoring.py +++ b/tests/test_services_monitoring.py @@ -223,10 +223,9 @@ async def test_weapon_access_from_dialog_facts(db: MemoryNotifier) -> None: assert snap is not None and snap.weapon_access is True await scale("cssrs_screen", 3, severity="moderate", scheduled=False) actions = await monitoring.RuleMonitoring().after_data_change(TOKEN) - assert {a.code for a in actions if a.type == "crisis"} == {"weapon_access_risk"} - plan = next(a for a in actions if a.type == "show_safety_plan") - assert plan.params["block"] == "means_restriction" - assert await color() == "red" + # Team decision 2026-10-04: weapon access is stored but triggers nothing (no crisis, no safety block). + assert "weapon_access_risk" not in {a.code for a in actions} + assert not any(a.type == "show_safety_plan" and a.params.get("block") == "means_restriction" for a in actions) async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None: