fix(bot): chat agent — ГРЛС excerpts in drug check, questions with alcohol answered, retry, clearer logs
CI / ruff · pytest (pull_request) Successful in 2m13s

Live run through the API (Andrey, real OpenRouter): onset questions now answered from the instruction
excerpt, «можно пиво?» reaches the agent although the check-in records alcohol, quit intent keeps the single
rule reaction, findings are mentioned only for intake/combination questions, one retry on a provider error,
provider error message logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:20:18 +03:00
co-authored by Claude Opus 5.5
parent 37f119610d
commit 83ad5d8b33
5 changed files with 44 additions and 3 deletions
+3 -1
View File
@@ -448,7 +448,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step:
await save_checkin(ctx, **fields) await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent: if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx)) text = await answer_question(ctx, await current_prescription(ctx))
if text is None and not fields and looks_like_question(ctx.text): # A question is answered even if it also mentioned alcohol/side effects (those are recorded above).
# Quit intent gets the rule reaction (delayed-onset explanation + doctor notified) — no second answer.
if text is None and not facts.quit_intent and looks_like_question(ctx.text):
text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64) text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64)
if text is None: if text is None:
text = await ctx.gateway.phrase("support") text = await ctx.gateway.phrase("support")
+12 -2
View File
@@ -64,6 +64,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
return None return None
async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]):
"""One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError)."""
wire = _wire(messages) if provider.name != "mock" else messages
try:
return await provider.chat(wire, TOOL_SPECS)
except ProviderError as exc:
log.warning("chat agent provider=%s retry after %s", provider.name, exc)
return await provider.chat(wire, TOOL_SPECS)
async def _patient_brief(ctx: ToolContext) -> str: async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers).""" """One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record() rec = await ctx.record()
@@ -99,7 +109,7 @@ async def answer_chat(
try: try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2): async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps): for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS) turn = await _chat_with_retry(provider, messages)
if not turn.tool_calls: if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged: if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results. # Some models (Gemini Flash) occasionally end with an empty message after tool results.
@@ -127,7 +137,7 @@ async def answer_chat(
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used)) log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
return None return None
except (ProviderError, TimeoutError) as exc: except (ProviderError, TimeoutError) as exc:
log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout") log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc)
return None return None
+4
View File
@@ -9,11 +9,15 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия). check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Вопросы «когда подействует», «что будет, если бросить», «как принимать», побочные эффекты — смотри поле
instruction в check_drug_for_patient (инструкция ГРЛС) и дополняй search_guidelines.
Для общих вопросов о лечении вызывай search_guidelines. Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в 2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми 3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно». словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Находки (findings) упоминай, когда вопрос о приёме, сочетании или безопасности препарата; на вопросы о
сроках действия, отмене, побочных эффектах отвечай по существу, без посторонних находок.
Про состав и непереносимости (intolerance_check) говори, только если о них спросили. Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions. Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», 4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
+6
View File
@@ -66,6 +66,12 @@ class OpenRouterProvider:
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise ProviderError(type(exc).__name__) from exc raise ProviderError(type(exc).__name__) from exc
if resp.status_code != 200: if resp.status_code != 200:
# Provider error bodies are generic (no patient text) — log the message to make failures diagnosable.
try:
detail = str(resp.json().get("error", {}).get("message", ""))[:200]
except ValueError:
detail = ""
log.error("openrouter http %s: %s", resp.status_code, detail)
raise ProviderError(f"http {resp.status_code}") raise ProviderError(f"http {resp.status_code}")
try: try:
msg = resp.json()["choices"][0]["message"] msg = resp.json()["choices"][0]["message"]
+19
View File
@@ -10,6 +10,7 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.integrations.base import integrations
from app.integrations.emias_record import PatientRecord, emias_records from app.integrations.emias_record import PatientRecord, emias_records
from app.llm.agent.drug_safety import check_patient, find_drug, load_db from app.llm.agent.drug_safety import check_patient, find_drug, load_db
from app.llm.rag import retrieve from app.llm.rag import retrieve
@@ -183,10 +184,12 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
if hit if hit
else "в примере состава этого вещества нет; состав зависит от производителя." else "в примере состава этого вещества нет; состав зависит от производителя."
) )
instruction = await _instruction(ctx, entry.inn)
return { return {
"found": True, "found": True,
"inn": entry.inn, "inn": entry.inn,
"record": True, "record": True,
"instruction": instruction, # ГРЛС excerpts: onset, side effects, discontinuation, administration
"findings": [f.model_dump() for f in findings], "findings": [f.model_dump() for f in findings],
"intolerance_check": intolerance_check, "intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None, "composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
@@ -196,5 +199,21 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
} }
INSTRUCTION_SECTIONS = ("onset", "side_effects", "discontinuation", "administration", "alcohol")
async def _instruction(ctx: ToolContext, inn: str) -> dict[str, str]:
"""ГРЛС excerpts for the drug (no doses in mg — see data/meds/grls)."""
try:
sections = await integrations.drug_info.sections(inn)
except Exception as exc: # noqa: BLE001
log.error("drug_info unavailable: %s", type(exc).__name__)
return {}
out = {s.section: s.text for s in sections if s.section in INSTRUCTION_SECTIONS}
if out:
ctx.cite(sections[0].source.split(",")[0] + ": инструкция к препарату")
return out
def _json(data: Any) -> str: def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False) return json.dumps(data, ensure_ascii=False)