From c489ddbdc4dc6a5d657000ad85cb15afb43826b5 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 17:42:03 +0300 Subject: [PATCH] feat(bot): chat answers questions in any state; OpenRouter via proxy from the RF server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/config.py | 1 + app/dialog/crisis.py | 4 +++ app/dialog/engine.py | 42 ++++++++++++++++++++++++++++- app/dialog/texts.py | 1 + app/llm/agent/providers.py | 3 ++- config/texts/dialog.yaml | 3 +++ tests/test_dialog_side_answer.py | 45 ++++++++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/test_dialog_side_answer.py diff --git a/app/config.py b/app/config.py index 55a6e73..aefce77 100644 --- a/app/config.py +++ b/app/config.py @@ -37,6 +37,7 @@ class Settings(BaseSettings): openrouter_api_key: str = "" openrouter_model: str = "google/gemini-2.5-flash-lite" 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 chat_agent_timeout_s: float = 25.0 chat_agent_max_steps: int = 5 diff --git a/app/dialog/crisis.py b/app/dialog/crisis.py index 6f85dda..9eaa4bd 100644 --- a/app/dialog/crisis.py +++ b/app/dialog/crisis.py @@ -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]) diff --git a/app/dialog/engine.py b/app/dialog/engine.py index c76543f..495bf48 100644 --- a/app/dialog/engine.py +++ b/app/dialog/engine.py @@ -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 diff --git a/app/dialog/texts.py b/app/dialog/texts.py index eaa42b7..8532308 100644 --- a/app/dialog/texts.py +++ b/app/dialog/texts.py @@ -113,6 +113,7 @@ class DialogTexts(_Strict): reach_out: ReachOutTexts visit: VisitTexts unavailable: str + side_answer_return: str = "Вернёмся к вопросу:" def dialog_texts_path() -> Path: diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py index 5ef6fb2..8bd5852 100644 --- a/app/llm/agent/providers.py +++ b/app/llm/agent/providers.py @@ -49,6 +49,7 @@ class OpenRouterProvider: self._model = settings.openrouter_model self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions" self._timeout = settings.chat_agent_timeout_s + self._proxy = settings.openrouter_proxy_url or None self._client = client 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: resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout) 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) except httpx.HTTPError as exc: raise ProviderError(type(exc).__name__) from exc diff --git a/config/texts/dialog.yaml b/config/texts/dialog.yaml index 60ebdad..a529ddd 100644 --- a/config/texts/dialog.yaml +++ b/config/texts/dialog.yaml @@ -118,3 +118,6 @@ visit: unavailable: |- Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже. Если вам плохо прямо сейчас — звоните 112 или на телефон доверия {phone:helpline}. + +# Answer to an off-script question is followed by the pending question again (engine side answer). +side_answer_return: "Вернёмся к вопросу:" diff --git a/tests/test_dialog_side_answer.py b/tests/test_dialog_side_answer.py new file mode 100644 index 0000000..aa22bd4 --- /dev/null +++ b/tests/test_dialog_side_answer.py @@ -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) -- 2.54.0