From 9ec61b9a03675524ec9c504f5f600479998810a6 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sun, 4 Oct 2026 14:59:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20chat=20agent=20on=20Gemini=202.5?= =?UTF-8?q?=20Flash-Lite=20=E2=80=94=20grounded=20first=20turn,=20current-?= =?UTF-8?q?prescription=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-tested on OpenRouter: Flash-Lite answers in 3–8 s with correct findings at ~$0.10/$0.40 per 1M tokens (qwen3.7-flash and gpt-oss-120b took 26–32 s). Force a tool call on the first turn, give the model the current prescription, let check_drug_for_patient default to it and return composition, intolerance check and general interactions (alcohol); retry once on an empty final message. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- app/config.py | 2 +- app/llm/agent/__init__.py | 18 +++++++++++++++++- app/llm/agent/prompts.py | 11 ++++++++--- app/llm/agent/providers.py | 5 ++++- app/llm/agent/tools.py | 31 ++++++++++++++++++++++++++----- tests/test_llm_agent.py | 28 ++++++++++++++++++++++++++++ 7 files changed, 85 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index a97c914..f1e04f5 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ GIGACHAT_MODEL=GigaChat # --- Chat agent: auto | off | mock | openrouter | gigachat. OpenRouter = temporary, synthetic data only. CHAT_AGENT=auto 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))" PSEUDO_KEY= diff --git a/app/config.py b/app/config.py index c5fc2d3..0f0069c 100644 --- a/app/config.py +++ b/app/config.py @@ -33,7 +33,7 @@ class Settings(BaseSettings): # data only — prod must use GigaChat (152-FZ, foreign services are forbidden for real PII). chat_agent: Literal["auto", "off", "mock", "openrouter", "gigachat"] = "auto" 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" chat_agent_timeout_s: float = 25.0 chat_agent_max_steps: int = 5 diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py index 1eef32c..a2242ed 100644 --- a/app/llm/agent/__init__.py +++ b/app/llm/agent/__init__.py @@ -34,6 +34,7 @@ from app.llm.guard import assert_scrubbed log = logging.getLogger(__name__) MAX_ANSWER_CHARS = 1200 +NUDGE = "Сформулируй ответ пациенту по результатам инструментов выше (3–6 предложений)." _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 +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]]: """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] @@ -82,14 +92,20 @@ async def answer_chat( return None ctx = ToolContext(token=token) messages: list[dict[str, Any]] = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": SYSTEM_PROMPT + await _patient_brief(ctx)}, {"role": "user", "content": str(question)}, ] + nudged = False try: async with asyncio.timeout(s.chat_agent_timeout_s * 2): for _ in range(s.chat_agent_max_steps): turn = await provider.chat(_wire(messages) if provider.name != "mock" else messages, TOOL_SPECS) 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) messages.append( { diff --git a/app/llm/agent/prompts.py b/app/llm/agent/prompts.py index c6c64f3..d056e87 100644 --- a/app/llm/agent/prompts.py +++ b/app/llm/agent/prompts.py @@ -6,11 +6,16 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе Как работать: 1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос - о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines. + о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими + средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через + check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия). + Для общих вопросов о лечении вызывай search_guidelines. 2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. -3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом - до приёма. Пустой список не значит «безопасно». +3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми + словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно». + Про состав и непереносимости (intolerance_check) говори, только если о них спросили. + Про алкоголь бери текст из general_interactions. 4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», «в вашей карте указано». diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py index 8ee0494..174e961 100644 --- a/app/llm/agent/providers.py +++ b/app/llm/agent/providers.py @@ -52,7 +52,10 @@ class OpenRouterProvider: self._client = client 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)"} try: if self._client is not None: diff --git a/app/llm/agent/tools.py b/app/llm/agent/tools.py index 0a17d0d..5575d5d 100644 --- a/app/llm/agent/tools.py +++ b/app/llm/agent/tools.py @@ -77,8 +77,9 @@ TOOL_SPECS: list[dict[str, Any]] = [ ), "parameters": { "type": "object", - "properties": {"drug": {"type": "string"}}, - "required": ["drug"], + "properties": { + "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]: - 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: return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."} 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": []} ctx.cite(entry.source) 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 { "found": True, "inn": entry.inn, "record": True, "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 не означает, что препарат безопасен: решение о приёме принимает врач.", } diff --git a/tests/test_llm_agent.py b/tests/test_llm_agent.py index 8662dba..daabcc7 100644 --- a/tests/test_llm_agent.py +++ b/tests/test_llm_agent.py @@ -169,3 +169,31 @@ def test_build_provider_modes(): 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="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 -- 2.54.0