fix: chat replies in any situation — no false crisis re-fire, answers in crisis, medicine questions anywhere
CI / ruff · pytest (pull_request) Successful in 2m22s
CI / ruff · pytest (pull_request) Successful in 2m22s
- An acked crisis is not raised again for the same signal: re-raised only if a newer triggering scale exists (seen live: AUDIT-C 0/0/0 re-fired the acked PHQ-9 item 9 crisis). - In an open crisis every non-acute message gets a reply (agent for questions, support otherwise) plus emergency contacts; acute messages still get the fixed script. - Questions and medicine mentions without «?» are answered mid-questionnaire; free-text steps (check-in, side effects, question states) keep parsing their input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -270,7 +270,8 @@ async def test_repeat_crisis_does_not_duplicate_script(dbs: None, engine: Engine
|
||||
assert r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text
|
||||
assert spy.calls == []
|
||||
r = await text(engine, "ладно")
|
||||
assert r[0].text == TEXTS.crisis.hold
|
||||
# In a crisis every message gets a human reply, followed by the emergency contacts (chat never goes silent).
|
||||
assert r[-1].kind == "crisis" and "112" in r[-1].text and r[0].kind != "crisis"
|
||||
_, data = await get_state()
|
||||
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
|
||||
|
||||
|
||||
@@ -3,9 +3,15 @@ buttons; in a crisis the answer carries the emergency contacts and the crisis st
|
||||
|
||||
from app.core import clock
|
||||
from app.db import repo, session
|
||||
from app.services.contracts import NoopMonitoring
|
||||
from tests.test_dialog_knowledge import TOKEN, buttons, engine, joined, press, say, state # noqa: F401
|
||||
|
||||
|
||||
class _InCrisis(NoopMonitoring):
|
||||
async def is_in_crisis(self, token: str) -> bool: # an open acute alert, as RuleMonitoring would report
|
||||
return True
|
||||
|
||||
|
||||
async def test_question_mid_scale_is_answered_and_question_returns(engine): # noqa: F811
|
||||
await press(engine, "menu:checkin") # some flow with buttons; then force a scale for determinism
|
||||
async with session.get_clinical_session() as s:
|
||||
@@ -25,6 +31,7 @@ async def test_question_mid_scale_is_answered_and_question_returns(engine): # n
|
||||
|
||||
|
||||
async def test_question_in_crisis_gets_answer_and_contacts(engine): # noqa: F811
|
||||
engine._monitoring = _InCrisis()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
|
||||
@@ -43,3 +50,24 @@ async def test_non_question_mid_scale_still_goes_to_the_scale(engine): # noqa:
|
||||
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
|
||||
replies = await say(engine, "ну так себе")
|
||||
assert "Вернёмся к вопросу" not in joined(replies)
|
||||
|
||||
|
||||
async def test_crisis_any_text_gets_a_reply_with_contacts(engine): # noqa: F811
|
||||
engine._monitoring = _InCrisis()
|
||||
async with session.get_clinical_session() as s:
|
||||
await repo.set_dialog_state(
|
||||
s, token=TOKEN, state="crisis", data={"consent": True, "crisis": {"open": True}}, now=clock.now()
|
||||
)
|
||||
for text in ("привет", "мне страшно", "что содержится в моих лекарствах"):
|
||||
replies = await say(engine, text)
|
||||
assert len(replies) >= 2, text
|
||||
assert replies[-1].kind == "crisis" and "112" in joined(replies)
|
||||
assert replies[0].kind != "crisis" # a real reply first, not only the stub
|
||||
|
||||
|
||||
async def test_medicine_mention_without_question_mark_mid_scale(engine): # noqa: F811
|
||||
from app.dialog.contracts import Event
|
||||
|
||||
await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
|
||||
replies = await say(engine, "что содержится в моих лекарствах")
|
||||
assert "Вернёмся к вопросу" in joined(replies) and buttons(replies)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""An acked crisis is not raised again for the same signal; a newer PHQ-9 with item 9 raises a new one."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.rules import Action
|
||||
from app.core.types import AlertLevel
|
||||
from app.services import monitoring
|
||||
|
||||
|
||||
class _A:
|
||||
def __init__(self, code, ts, scale=None):
|
||||
self.code, self.ts, self.scale = code, ts, scale
|
||||
|
||||
|
||||
async def test_drop_handled_crises(monkeypatch):
|
||||
t0 = datetime(2026, 10, 4, 16, 32)
|
||||
alerts = [_A("phq9_item9", t0 + timedelta(seconds=1))]
|
||||
results = [_A(None, t0, "phq9"), _A(None, t0 + timedelta(hours=2), "auditc")]
|
||||
|
||||
async def list_alerts(s, token):
|
||||
return alerts
|
||||
|
||||
async def list_scale_results(s, token):
|
||||
return results
|
||||
|
||||
monkeypatch.setattr(monitoring.repo, "list_alerts", list_alerts)
|
||||
monkeypatch.setattr(monitoring.repo, "list_scale_results", list_scale_results)
|
||||
crisis = Action(type="crisis", level=AlertLevel.ACUTE, code="phq9_item9")
|
||||
other = Action(type="alert", level=AlertLevel.HIGH, code="phq9_severe")
|
||||
assert await monitoring._drop_handled_crises(None, "t", [crisis, other]) == [other] # AUDIT-C is not a trigger
|
||||
results.append(_A(None, t0 + timedelta(hours=3), "phq9")) # a new PHQ-9 after the alert
|
||||
assert await monitoring._drop_handled_crises(None, "t", [crisis]) == [crisis]
|
||||
Reference in New Issue
Block a user