From e72378f178d0e6338f016baaf24c2967a032619a Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 22:43:18 +0300 Subject: [PATCH] =?UTF-8?q?fix(bot):=20agent=20polish=20=E2=80=94=20instan?= =?UTF-8?q?t=20small=20talk,=20capped=20tool=20calls,=20analogues,=20plain?= =?UTF-8?q?=20text,=20definitions=20allowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/llm/agent/__init__.py | 45 +++++++++++++++++++++++++++++++++------ app/llm/agent/prompts.py | 12 +++++++---- app/llm/gateway.py | 4 +++- tests/test_llm_agent.py | 24 +++++++++++++++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py index 7641a7f..379b252 100644 --- a/app/llm/agent/__init__.py +++ b/app/llm/agent/__init__.py @@ -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) diff --git a/app/llm/agent/prompts.py b/app/llm/agent/prompts.py index 45722b8..dfdd534 100644 --- a/app/llm/agent/prompts.py +++ b/app/llm/agent/prompts.py @@ -32,11 +32,15 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе - проводить психотерапию; обещать конфиденциальность сверх реальной; - выдумывать факты, которых нет в результатах инструментов. -На приветствия и болтовню («привет», «как дела?») отвечай коротко и тепло и предложи, с чем можешь помочь: -вопросы о лечении, лекарствах, побочных эффектах, самочувствии. Про замену препарата или аналоги: объясни, -что подбор и замену делает только врач, и при желании назови группу препарата по инструкции. +На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника +сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о +лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай. +Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение. +Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»), +скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач. -Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков. +Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#», +«**»; если нужен список — короткие строки с «•». Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон доверия {phone:helpline}, и что врач будет уведомлён.""" diff --git a/app/llm/gateway.py b/app/llm/gateway.py index aabfb32..7c832c3 100644 --- a/app/llm/gateway.py +++ b/app/llm/gateway.py @@ -43,7 +43,9 @@ FORBIDDEN_OUTPUT: tuple[re.Pattern[str], ...] = tuple( for p in ( r"диагноз", r"у (вас|тебя) (\w+ )?(депресси|птср|тревожн\w* расстройств|расстройств|биполяр|шизофрен)", - r"(это|похоже на|признаки) (\w+ )?(депресси|птср|посттравматическ|расстройств)", + # Diagnosis-like statements about the patient. A plain definition («ПТСР — это расстройство, которое…») + # is education, not a diagnosis, so bare «это …» is no longer blocked. + r"(похоже на|признаки|скорее всего,? у вас|у вас,? похоже) (\w+ )?(депресси|птср|посттравматическ|расстройств)", r"(увеличьте|уменьшите|снизьте|повысьте|удвойте|измените|увеличь|уменьши|снизь) (\w+ )?доз", r"(?