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 | 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
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату»,
|
||||||
«в вашей карте указано».
|
«в вашей карте указано».
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 не означает, что препарат безопасен: решение о приёме принимает врач.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user