Merge pull request 'fix(bot): полировка чат-агента' (#105) from fix/agent-polish into dev
CI / ruff · pytest (push) Successful in 2m45s
CD / ruff · pytest (push) Successful in 2m51s
CI / ruff · pytest (pull_request) Successful in 2m41s
CD / deploy dev (push) Successful in 3m27s

This commit was merged in pull request #105.
This commit is contained in:
2026-10-04 22:46:14 +03:00
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 asyncio
import json import json
import logging import logging
import re
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -35,6 +36,19 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200 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 предложений)." NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"} _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 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]]): 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).""" """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` wire = _wire(messages) if provider.name == "openrouter" else messages # gigachat needs the tool `name`
@@ -102,6 +125,8 @@ async def answer_chat(
except ValueError: except ValueError:
log.warning("chat agent: unscrubbed input rejected") log.warning("chat agent: unscrubbed input rejected")
return None return None
if is_small_talk(question):
return AgentAnswer(text=SMALL_TALK_REPLY, sources=[], tools=[], provider="small_talk")
ctx = ToolContext(token=token) ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)}, {"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
@@ -123,17 +148,17 @@ async def answer_chat(
{ {
"role": "assistant", "role": "assistant",
"content": turn.content, "content": turn.content,
"tool_calls": [ "tool_calls": [ # the calls we actually run (capped) — history must match the results
{ {
"id": c.id, "id": c.id,
"type": "function", "type": "function",
"function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)}, "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) result = await run_tool(ctx, c.name, c.arguments)
messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result}) 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)) 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) 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: def render(answer: AgentAnswer) -> str:
"""Patient-facing text: answer + short sources footer.""" """Patient-facing text: answer (markdown stripped to plain text with «•» bullets) + short sources footer."""
if not answer.sources: text = _MD_MARKS.sub("", _MD_BULLET.sub("• ", answer.text)).strip()
return answer.text if not answer.sources or len(text) < SOURCES_MIN_CHARS:
return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources) return text
return f"{text}\n\nИсточники: " + "; ".join(answer.sources)
+8 -4
View File
@@ -32,11 +32,15 @@ _SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
- проводить психотерапию; обещать конфиденциальность сверх реальной; - проводить психотерапию; обещать конфиденциальность сверх реальной;
- выдумывать факты, которых нет в результатах инструментов. - выдумывать факты, которых нет в результатах инструментов.
На приветствия и болтовню («привет», «как дела?») отвечай коротко и тепло и предложи, с чем можешь помочь: На приветствия и болтовню («привет», «как дела?», «спишь?») отвечай 1–2 фразами, тепло, от лица помощника
вопросы о лечении, лекарствах, побочных эффектах, самочувствии. Про замену препарата или аналоги: объясни, сервиса (не называй себя «языковой моделью» или «ИИ-моделью») и предложи, с чем можешь помочь: вопросы о
что подбор и замену делает только врач, и при желании назови группу препарата по инструкции. лечении, лекарствах, побочных эффектах, самочувствии. Находки из карты в ответ на болтовню не упоминай.
Никогда не переспрашивай, о каком препарате речь, если вопрос о «моём лекарстве»: это текущее назначение.
Про аналоги и замену: назови группу текущего препарата по инструкции (например, «антидепрессант группы СИОЗС»),
скажи, что в группе есть и другие препараты, а подбор и замену делает только лечащий врач.
Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков. Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений. Без markdown: никаких «*», «#»,
«**»; если нужен список — короткие строки с «•».
Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон
доверия {phone:helpline}, и что врач будет уведомлён.""" доверия {phone:helpline}, и что врач будет уведомлён."""
+3 -1
View File
@@ -43,7 +43,9 @@ FORBIDDEN_OUTPUT: tuple[re.Pattern[str], ...] = tuple(
for p in ( for p in (
r"диагноз", r"диагноз",
r"у (вас|тебя) (\w+ )?(депресси|птср|тревожн\w* расстройств|расстройств|биполяр|шизофрен)", 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"(увеличьте|уменьшите|снизьте|повысьте|удвойте|измените|увеличь|уменьши|снизь) (\w+ )?доз",
r"(?<!не )отмен(ите|и) ", r"(?<!не )отмен(ите|и) ",
r"(?<!не )(прекратите|перестаньте|бросьте) (\w+ )?(приём|прием|принимать|пить)", r"(?<!не )(прекратите|перестаньте|бросьте) (\w+ )?(приём|прием|принимать|пить)",
+24
View File
@@ -274,3 +274,27 @@ def test_build_provider_prefers_gigachat():
async def test_web_demo_token_shares_andreys_record(): async def test_web_demo_token_shares_andreys_record():
rec = await emias_records.get("tok-andrey") rec = await emias_records.get("tok-andrey")
assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions) assert rec is not None and any("язвенная" in c.name.lower() for c in rec.conditions)
@pytest.mark.parametrize("text", ["прив", "Привет!", "как дела у тебя ? что делаешь ? спишь ?", "добрый вечер"])
async def test_small_talk_without_llm(text):
provider, sent = _fake_openrouter("x", _CALLS)
ans = await agent.answer_chat(ANDREY, scrub(text), provider=provider, settings=_settings())
assert ans is not None and ans.provider == "small_talk" and sent == []
async def test_medical_question_is_not_small_talk():
assert not agent.is_small_talk("привет, можно мне ибупрофен?")
async def test_tool_calls_are_capped(sertraline):
many = [
{"id": f"s{i}", "type": "function", "function": {"name": "search_guidelines", "arguments": '{"query": "x"}'}}
for i in range(20)
]
provider, sent = _fake_openrouter("Ответ по рекомендациям, обсудите с врачом.", many)
ans = await agent.answer_chat(ANDREY, scrub("что такое ПТСР?"), provider=provider, settings=_settings())
assert ans is not None and len(ans.tools) == agent.MAX_TOOL_CALLS_PER_TURN
tool_msgs = [m for m in sent[1]["messages"] if m["role"] == "tool"]
assistant = [m for m in sent[1]["messages"] if m["role"] == "assistant"][0]
assert len(tool_msgs) == len(assistant["tool_calls"]) == agent.MAX_TOOL_CALLS_PER_TURN