feat(bot): chat agent on Gemini 2.5 Flash-Lite — grounded first turn, current-prescription defaults
CI / ruff · pytest (pull_request) Successful in 2m5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:59:38 +03:00
co-authored by Claude Opus 5.5
parent a2b1b2e7a5
commit 9ec61b9a03
7 changed files with 85 additions and 12 deletions
+17 -1
View File
@@ -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(
{