diff --git a/app/dialog/flows/veteran_daily.py b/app/dialog/flows/veteran_daily.py index 984e3ca..0f36020 100644 --- a/app/dialog/flows/veteran_daily.py +++ b/app/dialog/flows/veteran_daily.py @@ -448,7 +448,9 @@ async def _idle_text(ctx: Ctx, event: Event) -> Step: await save_checkin(ctx, **fields) if text is None and facts.med_question and not facts.quit_intent: text = await answer_question(ctx, await current_prescription(ctx)) - if text is None and not fields and looks_like_question(ctx.text): + # A question is answered even if it also mentioned alcohol/side effects (those are recorded above). + # Quit intent gets the rule reaction (delayed-onset explanation + doctor notified) — no second answer. + if text is None and not facts.quit_intent and looks_like_question(ctx.text): text = await knowledge_answer(ctx, explicit=False) # general question → clinical guidelines (#64) if text is None: text = await ctx.gateway.phrase("support") diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py index a2242ed..dc0386d 100644 --- a/app/llm/agent/__init__.py +++ b/app/llm/agent/__init__.py @@ -64,6 +64,16 @@ def build_provider(settings: Settings | None = None) -> ToolChatProvider | None: return None +async def _chat_with_retry(provider: ToolChatProvider, messages: list[dict[str, Any]]): + """One retry: OpenRouter occasionally fails a single call (seen live as intermittent ProviderError).""" + wire = _wire(messages) if provider.name != "mock" else messages + try: + return await provider.chat(wire, TOOL_SPECS) + except ProviderError as exc: + log.warning("chat agent provider=%s retry after %s", provider.name, exc) + return await provider.chat(wire, TOOL_SPECS) + + 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() @@ -99,7 +109,7 @@ async def answer_chat( 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) + turn = await _chat_with_retry(provider, messages) 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. @@ -127,7 +137,7 @@ async def answer_chat( log.warning("chat agent: step limit reached tools=%s", ",".join(ctx.used)) return None except (ProviderError, TimeoutError) as exc: - log.error("chat agent provider=%s failed: %s", provider.name, type(exc).__name__ or "timeout") + log.error("chat agent provider=%s failed: %s %s", provider.name, type(exc).__name__, exc) return None diff --git a/app/llm/agent/prompts.py b/app/llm/agent/prompts.py index d056e87..e7a8e27 100644 --- a/app/llm/agent/prompts.py +++ b/app/llm/agent/prompts.py @@ -9,11 +9,15 @@ SYSTEM_PROMPT = """Ты — помощник пациента в сервисе о «моих таблетках», для текущего назначения). Вопросы об алкоголе, еде, вождении, сочетании с другими средствами, составе и непереносимостях — тоже вопросы о лекарстве: проверь текущее назначение через check_drug_for_patient (там есть состав, сравнение с непереносимостями и все взаимодействия). + Вопросы «когда подействует», «что будет, если бросить», «как принимать», побочные эффекты — смотри поле + instruction в check_drug_for_patient (инструкция ГРЛС) и дополняй search_guidelines. Для общих вопросов о лечении вызывай search_guidelines. 2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. 3. Если check_drug_for_patient вернул findings — назови КАЖДУЮ находку (сначала severity=high) простыми словами и посоветуй обсудить с врачом до приёма. Пустой список не значит «безопасно». + Находки (findings) упоминай, когда вопрос о приёме, сочетании или безопасности препарата; на вопросы о + сроках действия, отмене, побочных эффектах отвечай по существу, без посторонних находок. Про состав и непереносимости (intolerance_check) говори, только если о них спросили. Про алкоголь бери текст из general_interactions. 4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py index 174e961..27524ba 100644 --- a/app/llm/agent/providers.py +++ b/app/llm/agent/providers.py @@ -66,6 +66,12 @@ class OpenRouterProvider: except httpx.HTTPError as exc: raise ProviderError(type(exc).__name__) from exc if resp.status_code != 200: + # Provider error bodies are generic (no patient text) — log the message to make failures diagnosable. + try: + detail = str(resp.json().get("error", {}).get("message", ""))[:200] + except ValueError: + detail = "" + log.error("openrouter http %s: %s", resp.status_code, detail) raise ProviderError(f"http {resp.status_code}") try: msg = resp.json()["choices"][0]["message"] diff --git a/app/llm/agent/tools.py b/app/llm/agent/tools.py index 5575d5d..2f2fd7d 100644 --- a/app/llm/agent/tools.py +++ b/app/llm/agent/tools.py @@ -10,6 +10,7 @@ import re from dataclasses import dataclass, field from typing import Any +from app.integrations.base import integrations from app.integrations.emias_record import PatientRecord, emias_records from app.llm.agent.drug_safety import check_patient, find_drug, load_db from app.llm.rag import retrieve @@ -183,10 +184,12 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]: if hit else "в примере состава этого вещества нет; состав зависит от производителя." ) + instruction = await _instruction(ctx, entry.inn) return { "found": True, "inn": entry.inn, "record": True, + "instruction": instruction, # ГРЛС excerpts: onset, side effects, discontinuation, administration "findings": [f.model_dump() for f in findings], "intolerance_check": intolerance_check, "composition": comp.model_dump(exclude={"excipient_tags"}) if comp else None, @@ -196,5 +199,21 @@ async def _check(ctx: ToolContext, name: str) -> dict[str, Any]: } +INSTRUCTION_SECTIONS = ("onset", "side_effects", "discontinuation", "administration", "alcohol") + + +async def _instruction(ctx: ToolContext, inn: str) -> dict[str, str]: + """ГРЛС excerpts for the drug (no doses in mg — see data/meds/grls).""" + try: + sections = await integrations.drug_info.sections(inn) + except Exception as exc: # noqa: BLE001 + log.error("drug_info unavailable: %s", type(exc).__name__) + return {} + out = {s.section: s.text for s in sections if s.section in INSTRUCTION_SECTIONS} + if out: + ctx.cite(sections[0].source.split(",")[0] + ": инструкция к препарату") + return out + + def _json(data: Any) -> str: return json.dumps(data, ensure_ascii=False)