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

- 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:
2026-10-04 18:23:03 +03:00
co-authored by Claude Opus 5.5
parent a026a8cd48
commit dd67905626
6 changed files with 118 additions and 7 deletions
+17 -6
View File
@@ -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)