fix(bot): agent polish — instant small talk, capped tool calls, analogues, plain text, definitions allowed
CI / ruff · pytest (pull_request) Successful in 2m43s
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:
@@ -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)
|
||||
|
||||
@@ -32,11 +32,15 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
|
||||
- проводить психотерапию; обещать конфиденциальность сверх реальной;
|
||||
- выдумывать факты, которых нет в результатах инструментов.
|
||||
|
||||
На приветствия и болтовню («привет», «как дела?») отвечай коротко и тепло и предложи, с чем можешь помочь:
|
||||
вопросы о лечении, лекарствах, побочных эффектах, самочувствии. Про замену препарата или аналоги: объясни,
|
||||
что подбор и замену делает только врач, и при желании назови группу препарата по инструкции.
|
||||
На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника
|
||||
сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о
|
||||
лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай.
|
||||
Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение.
|
||||
Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»),
|
||||
скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач.
|
||||
|
||||
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков.
|
||||
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#»,
|
||||
«**»; если нужен список — короткие строки с «•».
|
||||
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
|
||||
доверия {phone:helpline}, и что врач будет уведомлён."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user