Merge pull request 'fix(bot): полировка чат-агента' (#105) from fix/agent-polish into dev
This commit was merged in pull request #105.
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}, и что врач будет уведомлён."""
|
||||
|
||||
|
||||
+3
-1
@@ -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"(?<!не )отмен(ите|и) ",
|
||||
r"(?<!не )(прекратите|перестаньте|бросьте) (\w+ )?(приём|прием|принимать|пить)",
|
||||
|
||||
@@ -274,3 +274,27 @@ def test_build_provider_prefers_gigachat():
|
||||
async def test_web_demo_token_shares_andreys_record():
|
||||
rec = await emias_records.get("tok-andrey")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user