feat(bot): chat answers questions in any state; OpenRouter via proxy from the RF server
CI / ruff · pytest (pull_request) Successful in 2m53s
CI / ruff · pytest (pull_request) Successful in 2m53s
A question asked mid-questionnaire, mid-check-in or during a crisis is answered right away (agent → guidelines/instruction); the pending question is re-shown with its buttons (engine remembers the last prompt), in a crisis the answer carries the emergency contacts and the crisis stays open until the doctor's ack. Acute messages still get the fixed crisis script. OpenRouter blocks RF IPs: OPENROUTER_PROXY_URL routes the agent through an HTTP proxy, as vobot does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,10 @@ def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
|
||||
)
|
||||
|
||||
|
||||
def crisis_resource_lines(resources: list[CrisisResource]) -> list[str]:
|
||||
return _resource_lines(resources)
|
||||
|
||||
|
||||
def no_consent_crisis_text(script: CrisisScript) -> str:
|
||||
"""Crisis script without the "doctor notified" line — for people we may not (or did not) notify about."""
|
||||
return "\n".join([script.body, "", *_resource_lines(script.resources), "", script.grounding])
|
||||
|
||||
+41
-1
@@ -46,7 +46,7 @@ from app.db.session import get_clinical_session
|
||||
from app.dialog import scale_session
|
||||
from app.dialog.actions import execute
|
||||
from app.dialog.contracts import Event, Reply, register_engine
|
||||
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
|
||||
from app.dialog.crisis import crisis_open, crisis_resource_lines, no_consent_crisis_text, open_crisis
|
||||
from app.dialog.flows import load_flows
|
||||
from app.dialog.flows.core import close_crisis, revoke
|
||||
from app.dialog.fsm import (
|
||||
@@ -66,6 +66,9 @@ from app.services.contracts import MonitoringService, get_monitoring
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LAST_PROMPT = "last_prompt"
|
||||
# States with their own free-text handling (idle → flows' `text:idle`; explicit question states).
|
||||
SIDE_ANSWER_SKIP = frozenset({"idle", "kb.question", "meds.question"})
|
||||
# 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()
|
||||
@@ -143,6 +146,10 @@ class Engine:
|
||||
await repo.add_message(
|
||||
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
|
||||
)
|
||||
prompt = next((r for r in reversed(run.replies) if r.buttons), None)
|
||||
if prompt is not None and ctx.state not in RESTING_STATES:
|
||||
# Re-shown after an off-script question so the patient (and the web screen) gets the buttons back.
|
||||
ctx.data[LAST_PROMPT] = prompt.model_dump()
|
||||
if event.type != "system" or ctx.state != state_before:
|
||||
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
|
||||
ctx.data[STEP_AT] = ctx.now.isoformat()
|
||||
@@ -289,8 +296,41 @@ class Engine:
|
||||
step.notify_monitoring = True
|
||||
return step
|
||||
|
||||
side = await self._side_answer(ctx)
|
||||
if side is not None:
|
||||
return side
|
||||
return await self._dispatch(ctx, event)
|
||||
|
||||
async def _side_answer(self, ctx: Ctx) -> Step | None:
|
||||
"""A question asked in the middle of a questionnaire, a check-in or a crisis is answered right away
|
||||
(agent → guidelines/instruction), then the pending question is shown again with its buttons — the
|
||||
chat never ignores a question. Idle and the explicit question states have their own handlers.
|
||||
In a crisis the answer always carries the emergency contacts; the crisis itself stays open until the
|
||||
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)
|
||||
if not answer:
|
||||
return None
|
||||
log.info("dialog side answer token=%s state=%s crisis=%s", ctx.token, ctx.state, in_crisis)
|
||||
replies = [Reply(text=answer)]
|
||||
if in_crisis:
|
||||
script = crisis_reply([])
|
||||
replies.append(
|
||||
Reply(
|
||||
text="\n".join([ctx.texts.crisis.repeat, *crisis_resource_lines(script.resources)]), kind="crisis"
|
||||
)
|
||||
)
|
||||
elif ctx.data.get(LAST_PROMPT):
|
||||
last = Reply.model_validate(ctx.data[LAST_PROMPT])
|
||||
replies.append(last.model_copy(update={"text": f"{ctx.texts.side_answer_return}\n\n{last.text}"}))
|
||||
return Step(replies=replies)
|
||||
|
||||
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
|
||||
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
|
||||
return False
|
||||
|
||||
@@ -113,6 +113,7 @@ class DialogTexts(_Strict):
|
||||
reach_out: ReachOutTexts
|
||||
visit: VisitTexts
|
||||
unavailable: str
|
||||
side_answer_return: str = "Вернёмся к вопросу:"
|
||||
|
||||
|
||||
def dialog_texts_path() -> Path:
|
||||
|
||||
Reference in New Issue
Block a user