release: dev → main (финальная сдача) #114
+2
-1
@@ -25,7 +25,8 @@ DOCTOR_ID = "doc-smirnova"
|
|||||||
|
|
||||||
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
|
# Synthetic personas for the demo console and the demo ЕМИАС sign-in of the patient web app.
|
||||||
DEMO_PERSONAS: list[DemoPersona] = [
|
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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ async def auth_emias_demo(body: EmiasDemoIn) -> AuthOut:
|
|||||||
|
|
||||||
|
|
||||||
DEMO_SOURCE = "tok-andrey"
|
DEMO_SOURCE = "tok-andrey"
|
||||||
|
ONE_CLICK_NAME = "Андрей С." # «Демо: войти как Андрей»; the ЕМИАС demo persona is named in fixtures (Никита)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auth/demo/fresh", response_model=AuthOut)
|
@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)}"
|
token = f"{DEMO_SOURCE}-{secrets.token_hex(4)}"
|
||||||
now = clock.now()
|
now = clock.now()
|
||||||
persona = next(p for p in fixtures.DEMO_PERSONAS if p.token == DEMO_SOURCE)
|
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}"
|
snils = f"000-{secrets.randbelow(10**3):03d}-{secrets.randbelow(10**3):03d} {secrets.randbelow(100):02d}"
|
||||||
async with get_identity_session() as s:
|
async with get_identity_session() as s:
|
||||||
await identity_repo.create_patient(
|
await identity_repo.create_patient(
|
||||||
s,
|
s,
|
||||||
token=token,
|
token=token,
|
||||||
display_name=f"{persona.display_name} (демо)",
|
display_name=f"{name} (демо)",
|
||||||
snils=snils,
|
snils=snils,
|
||||||
clinic_id="demo",
|
clinic_id="demo",
|
||||||
doctor_id=fixtures.DOCTOR_ID,
|
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}
|
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)
|
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])
|
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)
|
@router.post("/auth/anonymous", response_model=AuthOut)
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ class Settings(BaseSettings):
|
|||||||
demo_clock: bool = True
|
demo_clock: bool = True
|
||||||
# Initial screening (onboarding questionnaires) on/off — the default; the demo console can flip it at runtime.
|
# Initial screening (onboarding questionnaires) on/off — the default; the demo console can flip it at runtime.
|
||||||
screening_enabled: bool = True
|
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"
|
tz: str = "Europe/Moscow"
|
||||||
|
|
||||||
api_base_url: str = "http://localhost:8000"
|
api_base_url: str = "http://localhost:8000"
|
||||||
|
|||||||
@@ -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)
|
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:
|
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 «Опросники
|
"""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
|
для MVP»: PHQ-2, trauma filter → TSQ, RUS-AUDIT-S, C-SSRS screener, the context questions; stage 2 (rest of
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Decisions:
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
|
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
|
||||||
from app.dialog.contracts import Reply
|
from app.dialog.contracts import Reply
|
||||||
from app.dialog.fsm import Ctx, Step
|
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)])
|
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
|
||||||
return Step(replies=[Reply(text=text, kind="crisis")])
|
return Step(replies=[Reply(text=text, kind="crisis")])
|
||||||
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
|
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(
|
return Step(
|
||||||
replies=[Reply(text=script.text, kind="crisis")],
|
replies=[Reply(text=script.text, kind="crisis")],
|
||||||
next_state="crisis",
|
next_state="crisis",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from weakref import WeakValueDictionary
|
|||||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.core import clock, safety
|
from app.core import clock, safety
|
||||||
from app.core.intake_runtime import intake_enabled
|
from app.core.intake_runtime import intake_enabled
|
||||||
from app.core.privacy import scrub
|
from app.core.privacy import scrub
|
||||||
@@ -68,6 +69,9 @@ from app.services.contracts import MonitoringService, get_monitoring
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LAST_PROMPT = "last_prompt"
|
LAST_PROMPT = "last_prompt"
|
||||||
|
DEFERRED_CRISIS = "deferred_crisis"
|
||||||
|
QUESTIONNAIRE_PREFIXES = ("scale.", "veteran.onboarding.", "intake.")
|
||||||
|
DEFERRABLE_KINDS = frozenset({"crisis", "safety_plan"})
|
||||||
MEDICINE_WORDS = re.compile(
|
MEDICINE_WORDS = re.compile(
|
||||||
r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I
|
r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I
|
||||||
)
|
)
|
||||||
@@ -145,6 +149,8 @@ class Engine:
|
|||||||
|
|
||||||
await self._apply(ctx, await self._start_pending(ctx), run)
|
await self._apply(ctx, await self._start_pending(ctx), run)
|
||||||
|
|
||||||
|
self._defer_crisis_messages(ctx, event, run)
|
||||||
|
|
||||||
if _consented(ctx):
|
if _consented(ctx):
|
||||||
for reply in run.replies:
|
for reply in run.replies:
|
||||||
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
|
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
|
||||||
@@ -177,6 +183,21 @@ class Engine:
|
|||||||
)
|
)
|
||||||
return run.replies
|
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:
|
def _abandoned(self, ctx: Ctx) -> bool:
|
||||||
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
|
"""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):
|
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import math
|
|||||||
from dataclasses import asdict, replace
|
from dataclasses import asdict, replace
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.core import interpretation
|
from app.core import interpretation
|
||||||
from app.core.intake_questions import (
|
from app.core.intake_questions import (
|
||||||
DIARY_KINDS,
|
DIARY_KINDS,
|
||||||
@@ -15,6 +16,7 @@ from app.core.intake_questions import (
|
|||||||
group_answers,
|
group_answers,
|
||||||
next_intake,
|
next_intake,
|
||||||
red_flag_category,
|
red_flag_category,
|
||||||
|
red_flag_triggers,
|
||||||
scale_question,
|
scale_question,
|
||||||
texts,
|
texts,
|
||||||
)
|
)
|
||||||
@@ -116,6 +118,39 @@ async def urgent(ctx, row=None, *, code="help_now", categories=None):
|
|||||||
return step
|
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):
|
async def item9_discrepancy(ctx, row):
|
||||||
profile = await ctx.profile()
|
profile = await ctx.profile()
|
||||||
if profile:
|
if profile:
|
||||||
@@ -170,18 +205,27 @@ async def manual_review(ctx, row, code):
|
|||||||
|
|
||||||
|
|
||||||
async def advance(ctx, row):
|
async def advance(ctx, row):
|
||||||
|
return await _flush_urgent(ctx, row, await _advance(ctx, row))
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
|
# Red flags (asked first): any «Да» stops the questionnaire with the help screen of its category.
|
||||||
for item_id, value in a.items():
|
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)
|
category = red_flag_category(item_id)
|
||||||
|
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 [])
|
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 →
|
# 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.
|
# stop + psychiatrist (about 24 h); low → flag, safety plan, continue.
|
||||||
if c.risk_lower_bound == "high":
|
if c.risk_lower_bound == "high":
|
||||||
|
if get_settings().crisis_blocking:
|
||||||
return await urgent(ctx, row, code="suicide_signal")
|
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:
|
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.
|
# Diary depression module, item 9 ≥1 → the C-SSRS right away («Этап 2»); its risk level decides.
|
||||||
return await item9_assessment(ctx, row)
|
return await item9_assessment(ctx, row)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from dataclasses import replace
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.core.privacy import scrub
|
from app.core.privacy import scrub
|
||||||
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
|
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
|
||||||
from app.db import repo
|
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
|
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]
|
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]
|
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)
|
return await _finish(ctx, scale, session, answers, result, crisis_flags)
|
||||||
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
|
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
|
||||||
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
|
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.
|
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
|
||||||
risk_screen = ctx.texts.settings.risk_screen_scale
|
risk_screen = ctx.texts.settings.risk_screen_scale
|
||||||
if crisis_open(ctx.data):
|
if crisis_open(ctx.data):
|
||||||
crisis = Step(next_state="crisis")
|
crisis = Step(next_state="crisis") if get_settings().crisis_blocking else Step()
|
||||||
else:
|
else:
|
||||||
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
|
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
|
||||||
step = done.then(crisis)
|
step = done.then(crisis)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ items:
|
|||||||
- id: rf_weapons
|
- id: rf_weapons
|
||||||
text: Есть ли у Вас дома или рядом доступ к оружию?
|
text: Есть ли у Вас дома или рядом доступ к оружию?
|
||||||
category: null
|
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
|
- id: rf_violence
|
||||||
text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие?
|
text: Угрожает ли Вам сейчас кто-то дома или применяет к Вам насилие?
|
||||||
category: domestic_violence
|
category: domestic_violence
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ rules:
|
|||||||
reason_ru: "C-SSRS: высокий риск (намерение/план или поведение)"
|
reason_ru: "C-SSRS: высокий риск (намерение/план или поведение)"
|
||||||
|
|
||||||
- id: weapon_access_risk
|
- id: weapon_access_risk
|
||||||
|
enabled: false # team decision 2026-10-04: weapon ownership is common in Russia — the answer triggers nothing
|
||||||
description: >
|
description: >
|
||||||
Access to weapons together with any suicide-risk signal (recent PHQ-9 item 9 >= 1 or C-SSRS >= moderate).
|
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.
|
Means restriction is one of the most evidence-based suicide-prevention measures → crisis safety block.
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ status: "черновик, утверждает психиатр (#8)"
|
|||||||
settings:
|
settings:
|
||||||
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
|
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
|
||||||
# Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию.
|
# Положительный п.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:
|
intro:
|
||||||
text: |-
|
text: |-
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
|
|||||||
monkeypatch.setenv("CHAT_AGENT", "off")
|
monkeypatch.setenv("CHAT_AGENT", "off")
|
||||||
monkeypatch.setenv("OPENROUTER_API_KEY", "")
|
monkeypatch.setenv("OPENROUTER_API_KEY", "")
|
||||||
monkeypatch.setenv("LLM_PROVIDER", "mock")
|
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()
|
get_settings.cache_clear()
|
||||||
yield
|
yield
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
assert (await results())["auditc"].severity == "positive" # unknown sex → sensitive threshold ≥ 3
|
||||||
# Weapon at home + AUDIT-C positive = risk signal → safe-storage block.
|
# Weapon at home + AUDIT-C positive = risk signal → safe-storage block.
|
||||||
r = await press(engine, "vonb:weapon:self")
|
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
|
# ----------------------------------------------------------------------------------------------- weapon / safety
|
||||||
|
|||||||
+3
-2
@@ -125,8 +125,9 @@ def test_cssrs_levels() -> None:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("weapon", "cssrs", "item9", "fires"),
|
("weapon", "cssrs", "item9", "fires"),
|
||||||
[
|
[
|
||||||
(True, "moderate", None, True),
|
# Team decision 2026-10-04: weapon access triggers nothing (rule disabled in config/rules.yaml).
|
||||||
(True, None, 1, True),
|
(True, "moderate", None, False),
|
||||||
|
(True, None, 1, False),
|
||||||
(True, "low", 0, False),
|
(True, "low", 0, False),
|
||||||
(False, "high", 2, False),
|
(False, "high", 2, False),
|
||||||
(None, "moderate", None, False),
|
(None, "moderate", None, False),
|
||||||
|
|||||||
@@ -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
|
assert snap is not None and snap.weapon_access is True
|
||||||
await scale("cssrs_screen", 3, severity="moderate", scheduled=False)
|
await scale("cssrs_screen", 3, severity="moderate", scheduled=False)
|
||||||
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
|
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
|
||||||
assert {a.code for a in actions if a.type == "crisis"} == {"weapon_access_risk"}
|
# Team decision 2026-10-04: weapon access is stored but triggers nothing (no crisis, no safety block).
|
||||||
plan = next(a for a in actions if a.type == "show_safety_plan")
|
assert "weapon_access_risk" not in {a.code for a in actions}
|
||||||
assert plan.params["block"] == "means_restriction"
|
assert not any(a.type == "show_safety_plan" and a.params.get("block") == "means_restriction" for a in actions)
|
||||||
assert await color() == "red"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None:
|
async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user