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:
@@ -37,6 +37,7 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str = ""
|
openrouter_api_key: str = ""
|
||||||
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
openrouter_model: str = "google/gemini-2.5-flash-lite"
|
||||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||||
|
openrouter_proxy_url: str = "" # OpenRouter blocks RF IPs: the server goes through an HTTP proxy (as vobot does)
|
||||||
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
|
gigachat_agent_model: str = "GigaChat-2-Pro" # function calling quality matters more than price here
|
||||||
chat_agent_timeout_s: float = 25.0
|
chat_agent_timeout_s: float = 25.0
|
||||||
chat_agent_max_steps: int = 5
|
chat_agent_max_steps: int = 5
|
||||||
|
|||||||
@@ -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:
|
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."""
|
"""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])
|
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 import scale_session
|
||||||
from app.dialog.actions import execute
|
from app.dialog.actions import execute
|
||||||
from app.dialog.contracts import Event, Reply, register_engine
|
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 import load_flows
|
||||||
from app.dialog.flows.core import close_crisis, revoke
|
from app.dialog.flows.core import close_crisis, revoke
|
||||||
from app.dialog.fsm import (
|
from app.dialog.fsm import (
|
||||||
@@ -66,6 +66,9 @@ from app.services.contracts import MonitoringService, get_monitoring
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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.
|
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
|
||||||
MAX_ENTER_CHAIN = 8
|
MAX_ENTER_CHAIN = 8
|
||||||
_patient_locks = WeakValueDictionary()
|
_patient_locks = WeakValueDictionary()
|
||||||
@@ -143,6 +146,10 @@ class Engine:
|
|||||||
await repo.add_message(
|
await repo.add_message(
|
||||||
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
|
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:
|
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.
|
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
|
||||||
ctx.data[STEP_AT] = ctx.now.isoformat()
|
ctx.data[STEP_AT] = ctx.now.isoformat()
|
||||||
@@ -289,8 +296,41 @@ class Engine:
|
|||||||
step.notify_monitoring = True
|
step.notify_monitoring = True
|
||||||
return step
|
return step
|
||||||
|
|
||||||
|
side = await self._side_answer(ctx)
|
||||||
|
if side is not None:
|
||||||
|
return side
|
||||||
return await self._dispatch(ctx, event)
|
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:
|
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
|
||||||
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
|
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class DialogTexts(_Strict):
|
|||||||
reach_out: ReachOutTexts
|
reach_out: ReachOutTexts
|
||||||
visit: VisitTexts
|
visit: VisitTexts
|
||||||
unavailable: str
|
unavailable: str
|
||||||
|
side_answer_return: str = "Вернёмся к вопросу:"
|
||||||
|
|
||||||
|
|
||||||
def dialog_texts_path() -> Path:
|
def dialog_texts_path() -> Path:
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class OpenRouterProvider:
|
|||||||
self._model = settings.openrouter_model
|
self._model = settings.openrouter_model
|
||||||
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
|
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
|
||||||
self._timeout = settings.chat_agent_timeout_s
|
self._timeout = settings.chat_agent_timeout_s
|
||||||
|
self._proxy = settings.openrouter_proxy_url or None
|
||||||
self._client = client
|
self._client = client
|
||||||
|
|
||||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
|
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
|
||||||
@@ -61,7 +62,7 @@ class OpenRouterProvider:
|
|||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
|
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy) as client:
|
||||||
resp = await client.post(self._url, json=body, headers=headers)
|
resp = await client.post(self._url, json=body, headers=headers)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise ProviderError(type(exc).__name__) from exc
|
raise ProviderError(type(exc).__name__) from exc
|
||||||
|
|||||||
@@ -118,3 +118,6 @@ visit:
|
|||||||
unavailable: |-
|
unavailable: |-
|
||||||
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
|
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
|
||||||
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
|
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}.
|
||||||
|
|
||||||
|
# Answer to an off-script question is followed by the pending question again (engine side answer).
|
||||||
|
side_answer_return: "Вернёмся к вопросу:"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Questions are answered in any state (side answer): mid-scale the pending question comes back with its
|
||||||
|
buttons; in a crisis the answer carries the emergency contacts and the crisis stays open."""
|
||||||
|
|
||||||
|
from app.core import clock
|
||||||
|
from app.db import repo, session
|
||||||
|
from tests.test_dialog_knowledge import TOKEN, buttons, engine, joined, press, say, state # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
|
||||||
|
from app.dialog.contracts import Event
|
||||||
|
|
||||||
|
first = await engine.handle(TOKEN, Event(type="system", system="scale_due", params={"scale": "phq9"}))
|
||||||
|
assert buttons(first), "scale question with buttons expected"
|
||||||
|
st_before, _ = await state()
|
||||||
|
replies = await say(engine, "Через сколько начнёт действовать лекарство?")
|
||||||
|
st_after, data = await state()
|
||||||
|
assert st_after == st_before # still inside the scale
|
||||||
|
assert "2–4 недели" in joined(replies) or "врач" in joined(replies).lower()
|
||||||
|
assert buttons(replies), "the pending question is shown again with its buttons"
|
||||||
|
assert "Вернёмся к вопросу" in joined(replies)
|
||||||
|
assert data.get("last_prompt")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_question_in_crisis_gets_answer_and_contacts(engine): # noqa: F811
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
replies = await say(engine, "Через сколько начнёт действовать лекарство?")
|
||||||
|
st, _ = await state()
|
||||||
|
assert st == "crisis"
|
||||||
|
kinds = [r.kind for r in replies]
|
||||||
|
assert "crisis" in kinds and len(replies) >= 2
|
||||||
|
assert "112" in joined(replies)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_question_mid_scale_still_goes_to_the_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 "Вернёмся к вопросу" not in joined(replies)
|
||||||
Reference in New Issue
Block a user