feat(bot): chat agent on Gemini 2.5 Flash-Lite — grounded first turn, current-prescription defaults
CI / ruff · pytest (pull_request) Successful in 2m5s
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:
+1
-1
@@ -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=
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
|
||||
«в вашей карте указано».
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+26
-5
@@ -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 не означает, что препарат безопасен: решение о приёме принимает врач.",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user