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:
+17
-6
@@ -28,6 +28,7 @@ Logs carry only the token, states and event codes (guardrail #8).
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -67,8 +68,12 @@ from app.services.contracts import MonitoringService, get_monitoring
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LAST_PROMPT = "last_prompt"
|
||||
MEDICINE_WORDS = re.compile(
|
||||
r"(лекарств|таблет|препарат|сертралин|пароксетин|флуоксетин|эсциталопрам|антидепрессант|побочк)", re.I
|
||||
)
|
||||
# States with their own free-text handling (idle → flows' `text:idle`; explicit question states).
|
||||
SIDE_ANSWER_SKIP = frozenset({"idle", "kb.question", "meds.question"})
|
||||
FREE_TEXT_PREFIXES = ("daily.", "meds.", "kb.") # steps that expect free text (check-in, side effects, questions)
|
||||
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
|
||||
MAX_ENTER_CHAIN = 8
|
||||
_patient_locks = WeakValueDictionary()
|
||||
@@ -309,12 +314,18 @@ class Engine:
|
||||
doctor acknowledges it, and acute messages never get here (handled above)."""
|
||||
from app.dialog.flows.knowledge import knowledge_answer, looks_like_question
|
||||
|
||||
in_crisis = crisis_open(ctx.data) or ctx.state == "crisis"
|
||||
if ctx.text is None or (ctx.state in SIDE_ANSWER_SKIP and not in_crisis):
|
||||
return None
|
||||
if not looks_like_question(ctx.text):
|
||||
return None
|
||||
answer = await knowledge_answer(ctx, explicit=True)
|
||||
in_crisis = (crisis_open(ctx.data) or ctx.state == "crisis") and await ctx.monitoring.is_in_crisis(ctx.token)
|
||||
free_text_step = ctx.state in SIDE_ANSWER_SKIP or ctx.state.startswith(FREE_TEXT_PREFIXES)
|
||||
if ctx.text is None or (free_text_step and not in_crisis):
|
||||
return None # idle / check-in / question states parse free text themselves
|
||||
asks = looks_like_question(ctx.text) or bool(MEDICINE_WORDS.search(ctx.text))
|
||||
if asks:
|
||||
answer = await knowledge_answer(ctx, explicit=True)
|
||||
elif in_crisis:
|
||||
# Not a question, but in a crisis every message gets a human reply (with contacts below), never silence.
|
||||
answer = await ctx.gateway.phrase("support")
|
||||
else:
|
||||
return None # e.g. a free-text answer to the current scale item — the step handles it
|
||||
if not answer:
|
||||
return None
|
||||
log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis)
|
||||
|
||||
@@ -51,6 +51,37 @@ async def refresh_status(session: AsyncSession, token: str) -> str:
|
||||
return status.color
|
||||
|
||||
|
||||
# Which data a crisis rule is triggered by: a crisis for the same code is raised again only when such data is
|
||||
# newer than the last alert with that code (acked or not) — otherwise an acked PHQ-9 item 9 re-fired on every
|
||||
# data change for 24 h (seen live: AUDIT-C answers 0/0/0 → crisis again).
|
||||
_CRISIS_TRIGGERS: dict[str, tuple[str, ...]] = {
|
||||
"phq9_item9": ("phq9",),
|
||||
"cssrs_high": ("cssrs_screen",),
|
||||
"cssrs_moderate": ("cssrs_screen",),
|
||||
"weapon_access_risk": ("phq9", "cssrs_screen"),
|
||||
}
|
||||
|
||||
|
||||
async def _drop_handled_crises(s: AsyncSession, token: str, actions: list[Action]) -> list[Action]:
|
||||
crisis_codes = {a.code for a in actions if a.type == "crisis"}
|
||||
if not crisis_codes:
|
||||
return actions
|
||||
alerts = await repo.list_alerts(s, token=token)
|
||||
results = await repo.list_scale_results(s, token)
|
||||
handled: set[str] = set()
|
||||
for code in crisis_codes:
|
||||
last_alert = max((a.ts for a in alerts if a.code == code), default=None)
|
||||
if last_alert is None:
|
||||
continue
|
||||
scales = _CRISIS_TRIGGERS.get(code)
|
||||
last_data = max((r.ts for r in results if scales is None or r.scale in scales), default=None)
|
||||
if last_data is None or last_data <= last_alert:
|
||||
handled.add(code)
|
||||
if handled:
|
||||
log.info("monitoring token=%s crisis already handled=%s", token, sorted(handled))
|
||||
return [a for a in actions if a.code not in handled]
|
||||
|
||||
|
||||
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
|
||||
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
|
||||
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
|
||||
@@ -68,6 +99,7 @@ class RuleMonitoring:
|
||||
return []
|
||||
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
|
||||
actions = _drop_running_crises(evaluate(snapshot), open_acute)
|
||||
actions = await _drop_handled_crises(s, token, actions)
|
||||
for action in actions:
|
||||
if action.type == "enhanced_monitoring":
|
||||
profile.enhanced_monitoring = True
|
||||
|
||||
Reference in New Issue
Block a user