fix(bot): agent polish — instant small talk, capped tool calls, analogues, plain text, definitions allowed
CI / ruff · pytest (pull_request) Successful in 2m43s

- Greetings / chit-chat answered without the LLM (instant, free, no medical findings dumped into «привет»).
- At most 4 tool calls per turn (one «как дела?» turn fired 23 parallel searches).
- «My drug» is never asked back; analogues → the drug's group, replacement only by the doctor.
- Markdown stripped to plain text with «•»; no sources footer under short replies.
- Diagnosis filter no longer blocks definitions («ПТСР — это расстройство…»); diagnosis-like statements about
  the patient are still blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 22:43:18 +03:00
co-authored by Claude Opus 5.5
parent f0be84d020
commit e72378f178
4 changed files with 73 additions and 12 deletions
+38 -7
View File
@@ -14,6 +14,7 @@ Guarantees (on top of what the dialog engine already did — scrub, consent, saf
import asyncio
import json
import logging
import re
from typing import Any
from pydantic import BaseModel
@@ -35,6 +36,19 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200
MAX_TOOL_CALLS_PER_TURN = 4 # seen live: one «как дела?» turn issued 23 parallel guideline searches
# Greetings / small talk: answered without the LLM (instant, free, never drags medical findings into «привет»).
SMALL_TALK = re.compile(
r"^\W*(прив\w*|здравств\w*|здаров\w*|добр(ый|ое|ого)\s+\w+|хай|hello|hi|ку)\b"
r"|\b(как\s+(дела|ты|вы|жизнь|поживаешь)|что\s+делаешь|чем\s+занят\w*|спишь|кто\s+ты|ты\s+(бот|робот|кто))\b",
re.I,
)
SMALL_TALK_MAX_WORDS = 8
SMALL_TALK_REPLY = (
"Здравствуйте! Я на связи. Могу рассказать о вашем лечении: как действует препарат и когда ждать эффекта, "
"какие бывают побочные эффекты, что можно и нельзя сочетать с вашими таблетками, что в составе. "
"Спросите — отвечу по вашей карте, инструкции и клиническим рекомендациям."
)
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
@@ -66,6 +80,15 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
return None
def is_small_talk(text: str) -> bool:
"""Short greeting / chit-chat without a medical question in it."""
words = re.findall(r"\w+", text)
medical = r"(лекарств|таблет|препарат|побоч|доз|лечени|можно|ибупрофен|нурофен|алкогол|пив|сертралин|сон|сплю)"
if len(words) > SMALL_TALK_MAX_WORDS or re.search(medical, text, re.I):
return False
return bool(SMALL_TALK.search(text))
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 == "openrouter" else messages # gigachat needs the tool `name`
@@ -102,6 +125,8 @@ async def answer_chat(
except ValueError:
log.warning("chat agent: unscrubbed input rejected")
return None
if is_small_talk(question):
return AgentAnswer(text=SMALL_TALK_REPLY, sources=[], tools=[], provider="small_talk")
ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
@@ -123,17 +148,17 @@ async def answer_chat(
{
"role": "assistant",
"content": turn.content,
"tool_calls": [
"tool_calls": [ # the calls we actually run (capped) — history must match the results
{
"id": c.id,
"type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)},
}
for c in turn.tool_calls
for c in turn.tool_calls[:MAX_TOOL_CALLS_PER_TURN]
],
}
)
for c in turn.tool_calls:
for c in turn.tool_calls[:MAX_TOOL_CALLS_PER_TURN]:
result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result})
log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used))
@@ -159,8 +184,14 @@ def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None:
return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider)
_MD_BULLET = re.compile(r"^\s*[*-]\s+", re.M)
_MD_MARKS = re.compile(r"\*\*|__|^#+\s*", re.M)
SOURCES_MIN_CHARS = 160 # short replies (greetings, small talk) go without the sources footer
def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer."""
if not answer.sources:
return answer.text
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources)
"""Patient-facing text: answer (markdown stripped to plain text with «•» bullets) + short sources footer."""
text = _MD_MARKS.sub("", _MD_BULLET.sub("• ", answer.text)).strip()
if not answer.sources or len(text) < SOURCES_MIN_CHARS:
return text
return f"{text}\n\nИсточники: " + "; ".join(answer.sources)