feat(bot): чат-агент на Gemini 2.5 Flash-Lite + доработки по живым прогонам #75

Merged
Eleboba merged 1 commits from feat/chat-agent-model into dev 2026-10-04 15:02:07 +03:00
7 changed files with 85 additions and 12 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ GIGACHAT_MODEL=GigaChat
# --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only. # --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only.
CHAT_AGENT=auto CHAT_AGENT=auto
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))" # --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY= PSEUDO_KEY=
+1 -1
View File
@@ -33,7 +33,7 @@ class Settings(BaseSettings):
# data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII). # data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII).
chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto" chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto"
openrouter_api_key: str = "" openrouter_api_key: str = ""
openrouter_model: str = "anthropic/claude-sonnet-4.5" openrouter_model: str = "google/gemini-2.5-flash-lite"
openrouter_base_url: str = "https://openrouter.ai/api/v1" openrouter_base_url: str = "https://openrouter.ai/api/v1"
chat_agent_timeout_s: float = 25.0 chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5 chat_agent_max_steps: int = 5
+17 -1
View File
@@ -34,6 +34,7 @@ from app.llm.guard import assert_scrubbed
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_ANSWER_CHARS = 1200 MAX_ANSWER_CHARS = 1200
NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)."
_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"} _ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"}
@@ -63,6 +64,15 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None:
return None return None
async def _patient_brief(ctx: ToolContext) -> str:
"""One line of context so the model knows what «мои таблетки» means (drug names only, no identifiers)."""
rec = await ctx.record()
if rec is None or not rec.current_prescriptions:
return "\n\nТекущее назначение психиатра: нет данных."
drugs = ", ".join(p.drug for p in rec.current_prescriptions)
return f"\n\nТекущее назначение психиатра: {drugs}. «Мои таблетки» = это назначение."
def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: def _wire(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip internal keys (tool `name`) before sending.""" """Strip internal keys (tool `name`) before sending."""
return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages] return [{k: v for k, v in m.items() if k in _ALLOWED_MSG_KEYS} for m in messages]
@@ -82,14 +92,20 @@ async def answer_chat(
return None return None
ctx = ToolContext(token=token) ctx = ToolContext(token=token)
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)},
{"role": "user", "content": str(question)}, {"role": "user", "content": str(question)},
] ]
nudged = False
try: try:
async with asyncio.timeout(s.chat_agent_timeout_s * 2): async with asyncio.timeout(s.chat_agent_timeout_s * 2):
for _ in range(s.chat_agent_max_steps): for _ in range(s.chat_agent_max_steps):
turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS) turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS)
if not turn.tool_calls: if not turn.tool_calls:
if not (turn.content or "").strip() and not nudged:
# Some models (Gemini Flash) occasionally end with an empty message after tool results.
nudged = True
messages.append({"role": "user", "content": NUDGE})
continue
return _finish(turn.content or "", ctx, provider.name) return _finish(turn.content or "", ctx, provider.name)
messages.append( messages.append(
{ {
+8 -3
View File
@@ -6,11 +6,16 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе
Как работать: Как работать:
1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда 1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда
вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос
о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines. о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими
средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через
check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия).
Для общих вопросов о лечении вызывай search_guidelines.
2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в 2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в
рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай.
3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом 3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми
до приёма. Пустой список не значит «безопасно». словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно».
Про состав и непереносимости (intolerance_check) говори, только если о них спросили.
Про алкоголь бери текст из general_interactions.
4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», 4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
«в вашей карте указано». «в вашей карте указано».
+4 -1
View File
@@ -52,7 +52,10 @@ class OpenRouterProvider:
self._client = client self._client = client
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn:
body = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1, "max_tokens": 900} body: dict[str, Any] = {"model": self._model, "messages": messages, "tools": tools, "temperature": 0.1}
body["max_tokens"] = 900
if not any(m["role"] == "tool" for m in messages):
body["tool_choice"] = "required" # never answer from the model's own memory: ground first
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"} headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
try: try:
if self._client is not None: if self._client is not None:
+26 -5
View File
@@ -77,8 +77,9 @@ TOOL_SPECS: list[dict[str, Any]] = [
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {"drug": {"type": "string"}}, "properties": {
"required": ["drug"], "drug": {"type": "string", "description": "Название препарата; пусто — текущее назначение пациента"}
},
}, },
}, },
}, },
@@ -157,7 +158,12 @@ def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]:
async def _check(ctx: ToolContext, name: str) -> dict[str, Any]: async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
entry = find_drug(name) entry = find_drug(name) if name.strip() else None
if entry is None:
# "мои таблетки" / no name → the current psychiatric prescription from the record.
rec = await ctx.record()
current = [find_drug(p.drug) for p in (rec.current_prescriptions if rec else [])]
entry = next((e for e in current if e is not None), None) if not find_drug(name) else None
if entry is None: if entry is None:
return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."} return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."}
rec = await ctx.record() rec = await ctx.record()
@@ -165,13 +171,28 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]:
return {"found": True, "inn": entry.inn, "record": False, "findings": []} return {"found": True, "inn": entry.inn, "record": False, "findings": []}
ctx.cite(entry.source) ctx.cite(entry.source)
ctx.cite("ЕМИАС: медицинская карта") ctx.cite("ЕМИАС: медицинская карта")
findings = check_patient(entry, rec, load_db()) db = load_db()
findings = check_patient(entry, rec, db)
comp = entry.composition
intolerances = [a.name for a in rec.allergies]
intolerance_check = None
if comp is not None and intolerances:
hit = {t for a in rec.allergies for t in a.tags} & set(comp.excipient_tags)
intolerance_check = f"Непереносимость из карты ({', '.join(intolerances)}): " + (
"вещество ЕСТЬ в составе."
if hit
else "в примере состава этого вещества нет; состав зависит от производителя."
)
return { return {
"found": True, "found": True,
"inn": entry.inn, "inn": entry.inn,
"record": True, "record": True,
"findings": [f.model_dump() for f in findings], "findings": [f.model_dump() for f in findings],
"note": "Пустой список не означает, что препарат безопасен: решение о приёме принимает врач.", "intolerance_check": intolerance_check,
"composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None,
"general_interactions": [i.text for i in entry.interactions], # incl. alcohol — for questions about it
"contraindications": entry.contraindications,
"note": "Пустой findings не означает, что препарат безопасен: решение о приёме принимает врач.",
} }
+28
View File
@@ -169,3 +169,31 @@ def test_build_provider_modes():
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider) assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None
async def test_check_defaults_to_current_prescription(sertraline):
out = json.loads(await run_tool(ToolContext(token=ANDREY), "check_drug_for_patient", {"drug": "мои таблетки"}))
assert out["found"] and out["inn"] == "сертралин"
assert "лактоз" in out["intolerance_check"] and "нет" in out["intolerance_check"]
assert any("Алкоголь" in t for t in out["general_interactions"])
async def test_empty_final_answer_is_nudged(sertraline):
replies = iter(
[
{"role": "assistant", "content": None, "tool_calls": _CALLS},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": "Алкоголь во время лечения не рекомендуется. Обсудите с врачом."},
]
)
sent = []
def handler(request):
sent.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": next(replies)}]})
provider = OpenRouterProvider(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
ans = await agent.answer_chat(ANDREY, scrub("пиво можно?"), provider=provider, settings=_settings())
assert ans is not None and "Алкоголь" in ans.text
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt