diff --git a/.env.example b/.env.example index a5b05af..a97c914 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ GIGACHAT_CREDENTIALS= GIGACHAT_SCOPE=GIGACHAT_API_PERS 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 + # --- 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 810cdc8..c5fc2d3 100644 --- a/app/config.py +++ b/app/config.py @@ -28,6 +28,16 @@ class Settings(BaseSettings): gigachat_model: str = "GigaChat" gigachat_verify_ssl: bool = False + # Chat agent (tool-using LLM over RAG + patient record). `auto` = OpenRouter if a key is set, else off + # (extractive RAG / instruction answers stay). OpenRouter is a TEMPORARY prototype provider on synthetic + # 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_base_url: str = "https://openrouter.ai/api/v1" + chat_agent_timeout_s: float = 25.0 + chat_agent_max_steps: int = 5 + # Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi. tg_bot_token: str = "" doctor_notify_chat_id: int | None = None diff --git a/app/dialog/flows/knowledge.py b/app/dialog/flows/knowledge.py index 00b2382..55ac0b9 100644 --- a/app/dialog/flows/knowledge.py +++ b/app/dialog/flows/knowledge.py @@ -21,7 +21,7 @@ from app.config import get_settings from app.dialog.contracts import Event, Reply from app.dialog.flows.core import menu_reply from app.dialog.fsm import Ctx, Step, abandonable, handler_for, state, trigger -from app.llm import rag +from app.llm import agent, rag from app.llm.med_qa import DOSE_QUESTION log = logging.getLogger(__name__) @@ -74,6 +74,11 @@ async def knowledge_answer(ctx: Ctx, *, explicit: bool) -> str | None: if DOSE_QUESTION.search(ctx.text.lower()): log.info("kb: dose question redirected to doctor") return load_knowledge_texts().dose_redirect + # Tool-using agent first (RAG + ЕМИАС record + drug-safety check); disabled/failed → extractive RAG below. + smart = await agent.answer_chat(ctx.token, ctx.text) + if smart is not None: + log.info("kb: agent answered provider=%s tools=%s", smart.provider, ",".join(smart.tools)) + return agent.render(smart) reply = await rag.answer(ctx.text, audience="patient") if reply is not None: log.info("kb: answered docs=%s", ",".join(c.doc for c in reply.citations)) diff --git a/app/dialog/flows/meds.py b/app/dialog/flows/meds.py index d079160..47f543b 100644 --- a/app/dialog/flows/meds.py +++ b/app/dialog/flows/meds.py @@ -42,7 +42,8 @@ from app.dialog.contracts import Button, Event, Reply from app.dialog.flows.core import menu_reply from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload from app.integrations.base import integrations -from app.llm.med_qa import fallback_text, med_qa +from app.llm import agent +from app.llm.med_qa import DOSE_QUESTION, fallback_text, med_qa QUESTION_STATE = "meds.question" SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text" @@ -435,6 +436,11 @@ async def answer_question(ctx: Ctx, rx: Rx | None) -> str: """Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача».""" if rx is None or ctx.text is None: return load_meds_texts().question.no_prescription + if DOSE_QUESTION.search(ctx.text.lower()) is None: + # Agent: instruction + ЕМИАС record (other drugs, conditions, intolerances) + guidelines; else excerpt only. + smart = await agent.answer_chat(ctx.token, ctx.text) + if smart is not None: + return agent.render(smart) answer = await med_qa(rx.drug, ctx.text) return answer.text if answer is not None else fallback_text() diff --git a/app/integrations/emias_record.py b/app/integrations/emias_record.py new file mode 100644 index 0000000..09624f0 --- /dev/null +++ b/app/integrations/emias_record.py @@ -0,0 +1,94 @@ +"""ЕМИАС medical record mock: diagnoses, comorbidities, allergies and other specialists' prescriptions. + +Prod: the regional МИС / ЕГИСЗ adapter replaces `EmiasRecordSource` (same `get(token)` contract). Demo: synthetic +records from `data/emias/records.yaml` keyed by synthetic SNILS, converted to tokens with `pseudo_id()` at load +time — so the record handed to the chat agent carries no SNILS, name or phone (guardrail #1). The current +psychiatric prescription comes from the МИС mock (`integrations.prescriptions`). +""" + +import logging +from functools import lru_cache +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field + +from app.config import get_settings +from app.core.privacy import pseudo_id +from app.integrations.base import integrations + +log = logging.getLogger(__name__) + + +class Diagnosis(BaseModel): + code: str + name: str + + +class Condition(BaseModel): + name: str + tags: list[str] = Field(default_factory=list) + + +class OtherMedication(BaseModel): + drug: str + how: str = "" + prescribed_by: str = "" + classes: list[str] = Field(default_factory=list) + + +class CurrentPrescription(BaseModel): + drug: str + dose: str + instructions: str = "" + + +class PatientRecord(BaseModel): + """Clinical-only view of the record (no identifiers): what the chat agent may see.""" + + diagnoses: list[Diagnosis] = Field(default_factory=list) + conditions: list[Condition] = Field(default_factory=list) + allergies: list[Condition] = Field(default_factory=list) + other_medications: list[OtherMedication] = Field(default_factory=list) + pregnancy: dict[str, str] | None = None + current_prescriptions: list[CurrentPrescription] = Field(default_factory=list) + + +def default_records_path() -> Path: + return get_settings().data_dir / "emias" / "records.yaml" + + +@lru_cache +def _load(path: Path) -> dict[str, PatientRecord]: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + out: dict[str, PatientRecord] = {} + for snils, rec in (raw.get("records") or {}).items(): + out[pseudo_id(snils)] = PatientRecord.model_validate(rec) + return out + + +class EmiasRecordSource: + def __init__(self, path: Path | None = None) -> None: + self._path = path + + async def get(self, token: str) -> PatientRecord | None: + try: + base = _load(self._path or default_records_path()).get(token) + except (OSError, yaml.YAMLError, ValueError) as exc: + log.error("emias records unavailable: %s", type(exc).__name__) + base = None + record = base.model_copy(deep=True) if base is not None else PatientRecord() + try: + rx = await integrations.prescriptions.get_prescriptions(token) + except Exception as exc: # noqa: BLE001 — the МИС being down must not break the chat + log.error("prescriptions unavailable: %s", type(exc).__name__) + rx = [] + record.current_prescriptions = [ + CurrentPrescription(drug=p.drug, dose=p.dose, instructions=p.instructions) for p in rx + ] + if base is None and not rx: + return None + return record + + +emias_records = EmiasRecordSource() diff --git a/app/llm/agent/__init__.py b/app/llm/agent/__init__.py new file mode 100644 index 0000000..1eef32c --- /dev/null +++ b/app/llm/agent/__init__.py @@ -0,0 +1,135 @@ +"""Chat agent: tool-using LLM over the knowledge base (RAG), the ЕМИАС record and the drug-safety reference. + + from app.llm.agent import answer_chat + reply = await answer_chat(token, scrubbed_question) # None → caller falls back to extractive RAG / med_qa + +Guarantees (on top of what the dialog engine already did — scrub, consent, safety check; ACUTE never gets here): + * only scrubbed text goes to the provider (`assert_scrubbed`), tool results carry no identifiers; + * tools are bound to the current token server-side; read-only; + * the final text passes the gateway's forbidden-output filter (diagnoses, dose/drug advice); a violation or any + provider error → None, so the patient gets the safe extractive answer instead; + * logs carry tool names and counts only. +""" + +import asyncio +import json +import logging +from typing import Any + +from pydantic import BaseModel + +from app.config import Settings, get_settings +from app.core.types import ScrubbedText +from app.llm.agent.prompts import SYSTEM_PROMPT +from app.llm.agent.providers import ( + MockAgentProvider, + OpenRouterProvider, + ProviderError, + ToolChatProvider, +) +from app.llm.agent.tools import TOOL_SPECS, ToolContext, run_tool +from app.llm.gateway import is_forbidden +from app.llm.guard import assert_scrubbed + +log = logging.getLogger(__name__) + +MAX_ANSWER_CHARS = 1200 +_ALLOWED_MSG_KEYS = {"role", "content", "tool_calls", "tool_call_id"} + + +class AgentAnswer(BaseModel): + text: str + sources: list[str] + tools: list[str] + provider: str + + +def build_provider(settings: Settings | None = None) -> ToolChatProvider | None: + s = settings or get_settings() + mode = s.chat_agent + if mode == "auto": + mode = "openrouter" if s.openrouter_api_key else "off" + if mode == "openrouter": + if not s.openrouter_api_key: + log.warning("chat agent: openrouter selected without OPENROUTER_API_KEY — disabled") + return None + return OpenRouterProvider(s) + if mode == "mock": + return MockAgentProvider() + if mode == "gigachat": + # TODO(GigaChat): function calling via the `gigachat` package (functions=..., message.function_call). + log.warning("chat agent: gigachat provider not implemented yet — disabled") + return None + return None + + +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] + + +async def answer_chat( + token: str, question: ScrubbedText, *, provider: ToolChatProvider | None = None, settings: Settings | None = None +) -> AgentAnswer | None: + s = settings or get_settings() + provider = provider or build_provider(s) + if provider is None: + return None + try: + assert_scrubbed(question) + except ValueError: + log.warning("chat agent: unscrubbed input rejected") + return None + ctx = ToolContext(token=token) + messages: list[dict[str, Any]] = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": str(question)}, + ] + 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: + return _finish(turn.content or "", ctx, provider.name) + messages.append( + { + "role": "assistant", + "content": turn.content, + "tool_calls": [ + { + "id": c.id, + "type": "function", + "function": {"name": c.name, "arguments": json.dumps(c.arguments, ensure_ascii=False)}, + } + for c in turn.tool_calls + ], + } + ) + for c in turn.tool_calls: + result = await run_tool(ctx, c.name, c.arguments) + messages.append({"role": "tool", "tool_call_id": c.id, "name": c.name, "content": result}) + 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") + return None + + +def _finish(text: str, ctx: ToolContext, provider: str) -> AgentAnswer | None: + text = text.strip() + if not text: + return None + if is_forbidden(text): + log.warning("chat agent: forbidden output filtered provider=%s", provider) + return None + if len(text) > MAX_ANSWER_CHARS: + text = text[:MAX_ANSWER_CHARS].rsplit(" ", 1)[0] + "…" + log.info("chat agent ok provider=%s tools=%s sources=%d", provider, ",".join(ctx.used), len(ctx.sources)) + return AgentAnswer(text=text, sources=ctx.sources[:4], tools=ctx.used, provider=provider) + + +def render(answer: AgentAnswer) -> str: + """Patient-facing text: answer + short sources footer.""" + if not answer.sources: + return answer.text + return f"{answer.text}\n\nИсточники: " + "; ".join(answer.sources) diff --git a/app/llm/agent/__main__.py b/app/llm/agent/__main__.py new file mode 100644 index 0000000..9bec852 --- /dev/null +++ b/app/llm/agent/__main__.py @@ -0,0 +1,59 @@ +"""Try the chat agent from the shell (synthetic personas only): + + uv run python -m app.llm.agent "Можно мне ибупрофен вместе с моими таблетками?" --persona andrey + CHAT_AGENT=mock uv run python -m app.llm.agent "Есть ли лактоза в сертралине?" + +Prescriptions come from the МИС mock (DB if reachable, else data/meds/mis_prescriptions.json); the question is +scrubbed before it reaches any provider. +""" + +import argparse +import asyncio +import logging + +from app.config import get_settings +from app.core.privacy import pseudo_id, scrub +from app.integrations.base import Prescription, integrations +from app.llm.agent import answer_chat, build_provider, render + +PERSONAS = { # synthetic SNILS from scripts/seed_demo.py → current psychiatric prescription + "andrey": ("900-000-013 34", "сертралин"), + "dmitry": ("900-000-041 01", "сертралин"), + "sergey": ("900-000-037 02", "пароксетин"), + "nikolay": ("900-000-052 03", "сертралин"), + "anna": ("900-000-023 04", "флуоксетин"), + "pavel": ("900-000-045 07", "эсциталопрам"), +} + + +class _FixedRx: + def __init__(self, drug: str) -> None: + self.drug = drug + + async def get_prescriptions(self, token: str) -> list[Prescription]: + return [Prescription(drug=self.drug, dose="по назначению врача")] + + +async def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("question") + p.add_argument("--persona", default="andrey", choices=sorted(PERSONAS)) + p.add_argument("-v", "--verbose", action="store_true") + a = p.parse_args() + logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING) + snils, drug = PERSONAS[a.persona] + integrations.prescriptions = _FixedRx(drug) # CLI runs without the DB + provider = build_provider() + if provider is None: + raise SystemExit( + f"agent disabled (CHAT_AGENT={get_settings().chat_agent}); set OPENROUTER_API_KEY or CHAT_AGENT=mock" + ) + ans = await answer_chat(pseudo_id(snils), scrub(a.question), provider=provider) + if ans is None: + raise SystemExit("no answer (provider error, filtered output or nothing found) — chat falls back to RAG") + print(render(ans)) + print(f"\n[provider={ans.provider} tools={', '.join(ans.tools)}]") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/llm/agent/drug_safety.py b/app/llm/agent/drug_safety.py new file mode 100644 index 0000000..a38a24b --- /dev/null +++ b/app/llm/agent/drug_safety.py @@ -0,0 +1,147 @@ +"""Deterministic drug-safety lookup: composition, contraindications, interactions, patient-specific findings. + +The LLM never decides whether a drug is safe for the patient (ADR-007 spirit): it calls `check_patient` and +retells the findings. Matching is by tags shared with the ЕМИАС record (`data/emias/records.yaml`): +condition tags → `cautions[].when_condition`, allergy tags → `composition.excipient_tags`, other medication +classes → `interactions[].with_class`. Source data: `data/meds/drug_safety.yaml` (prototype retelling of ГРЛС). +""" + +from functools import lru_cache +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field + +from app.config import get_settings +from app.integrations.emias_record import PatientRecord + +Severity = Literal["high", "medium", "low"] + + +class Caution(BaseModel): + when_condition: str + severity: Severity + text: str + + +class Interaction(BaseModel): + with_class: str + severity: Severity + text: str + + +class Composition(BaseModel): + form: str + active: str + excipients: list[str] = Field(default_factory=list) + excipient_tags: list[str] = Field(default_factory=list) + note: str = "" + + +class DrugEntry(BaseModel): + inn: str + aliases: list[str] = Field(default_factory=list) + drug_class: str = "" + classes: list[str] = Field(default_factory=list) # classes this drug belongs to (e.g. nsaid, ssri) + contraindications: list[str] = Field(default_factory=list) + cautions: list[Caution] = Field(default_factory=list) + interactions: list[Interaction] = Field(default_factory=list) + composition: Composition | None = None + pregnancy: str = "" + source: str = "" + + +class Finding(BaseModel): + severity: Severity + kind: Literal["condition", "allergy", "interaction", "pregnancy", "contraindication"] + text: str + + +class SafetyDb(BaseModel): + drugs: dict[str, DrugEntry] + allergy_labels: dict[str, str] = Field(default_factory=dict) + + +def _path() -> Path: + return get_settings().data_dir / "meds" / "drug_safety.yaml" + + +@lru_cache +def load_db(path: Path | None = None) -> SafetyDb: + raw = yaml.safe_load((path or _path()).read_text(encoding="utf-8")) + classes_of: dict[str, list[str]] = {} + for cls, members in (raw.get("drug_classes") or {}).items(): + for m in members: + classes_of.setdefault(m, []).append(cls) + drugs: dict[str, DrugEntry] = {} + for inn, d in (raw.get("drugs") or {}).items(): + entry = DrugEntry( + inn=inn, + aliases=d.get("aliases", []), + drug_class=d.get("class", ""), + classes=sorted(set(d.get("classes", [])) | set(classes_of.get(inn, []))), + contraindications=d.get("contraindications", []), + cautions=d.get("cautions", []), + interactions=d.get("interactions", []), + composition=d.get("composition"), + pregnancy=d.get("pregnancy", ""), + source=d.get("source", ""), + ) + drugs[inn] = entry + return SafetyDb(drugs=drugs, allergy_labels=raw.get("allergy_labels") or {}) + + +def find_drug(name: str, db: SafetyDb | None = None) -> DrugEntry | None: + """By INN or alias, case-insensitive; "сертралин 50 мг" → сертралин.""" + db = db or load_db() + words = [w.strip(".,!?;:«»()") for w in name.lower().replace("ё", "е").split()] + for entry in db.drugs.values(): + names = {entry.inn, *(a.lower() for a in entry.aliases)} + # Russian case endings: "сертралине", "ибупрофена" — match on the stem (name minus the last letter). + if any(w in names or any(len(n) > 4 and w.startswith(n[:-1]) for n in names) for w in words): + return entry + return None + + +def check_patient(drug: DrugEntry, record: PatientRecord, db: SafetyDb | None = None) -> list[Finding]: + """Patient-specific findings for `drug`, most severe first. Empty list = nothing found in the reference — + which is NOT a statement that the drug is safe (the answer must say so).""" + db = db or load_db() + out: list[Finding] = [] + cond_tags = {t for c in record.conditions for t in c.tags} + for c in drug.cautions: + if c.when_condition in cond_tags: + out.append(Finding(severity=c.severity, kind="condition", text=c.text)) + # Excipients vs allergies / intolerances. + allergy_tags = {t for a in record.allergies for t in a.tags} + if drug.composition is not None: + for tag in sorted(allergy_tags & set(drug.composition.excipient_tags)): + label = db.allergy_labels.get(tag, tag) + out.append( + Finding( + severity="high", kind="allergy", text=f"В составе есть {label}, а в карте отмечена непереносимость." + ) + ) + # Interactions with the patient's other medicines and with the current psychiatric prescription. + other_classes: dict[str, str] = {} + for m in record.other_medications: + for cls in m.classes: + other_classes[cls] = m.drug + for rx in record.current_prescriptions: + entry = find_drug(rx.drug, db) + if entry is not None and entry.inn != drug.inn: + for cls in entry.classes: + other_classes.setdefault(cls, rx.drug) + for i in drug.interactions: + if i.with_class in other_classes: + out.append( + Finding( + severity=i.severity, kind="interaction", text=f"{i.text} (в карте: {other_classes[i.with_class]})." + ) + ) + if record.pregnancy and drug.pregnancy: + out.append(Finding(severity="medium", kind="pregnancy", text=drug.pregnancy)) + order = {"high": 0, "medium": 1, "low": 2} + out.sort(key=lambda f: order[f.severity]) + return out diff --git a/app/llm/agent/prompts.py b/app/llm/agent/prompts.py new file mode 100644 index 0000000..c6c64f3 --- /dev/null +++ b/app/llm/agent/prompts.py @@ -0,0 +1,26 @@ +"""System prompt of the chat agent (Russian). Prohibitions follow SPEC §7; grounding: answer only from tools.""" + +SYSTEM_PROMPT = """Ты — помощник пациента в сервисе сопровождения лечения между визитами к врачу. Пациент лечится у +психиатра; ты отвечаешь на его вопросы о состоянии, лечении и лекарствах. + +Как работать: +1. Сначала собери данные инструментами. Для вопросов о лекарствах, сочетаниях, составе, аллергиях всегда + вызывай get_patient_record и check_drug_for_patient (для каждого упомянутого препарата и, если вопрос + о «моих таблетках», для текущего назначения). Для общих вопросов о лечении вызывай search_guidelines. +2. Отвечай ТОЛЬКО на основе результатов инструментов. Если в них нет ответа — честно скажи, что в + рекомендациях и инструкции этого нет, и предложи уточнить у лечащего врача. Ничего не додумывай. +3. Если check_drug_for_patient вернул находки — назови их простыми словами и посоветуй обсудить с врачом + до приёма. Пустой список не значит «безопасно». +4. Ссылайся на источник в тексте коротко: «по клиническим рекомендациям», «по инструкции к препарату», + «в вашей карте указано». + +Запрещено: +- ставить или называть диагнозы («у вас депрессия»), даже если они есть в карте; +- назначать, отменять препараты, менять дозы, советовать «примите/не принимайте» — решение всегда за врачом; +- называть дозировки в миллиграммах; +- проводить психотерапию; обещать конфиденциальность сверх реальной; +- выдумывать факты, которых нет в результатах инструментов. + +Стиль: по-русски, на «вы», спокойно и по-человечески, 3–6 коротких предложений, без markdown-заголовков. +Если в вопросе есть признаки угрозы жизни — коротко скажи, что важно сразу позвонить 112 или на телефон +доверия 8-800-2000-122, и что врач будет уведомлён.""" diff --git a/app/llm/agent/providers.py b/app/llm/agent/providers.py new file mode 100644 index 0000000..8ee0494 --- /dev/null +++ b/app/llm/agent/providers.py @@ -0,0 +1,176 @@ +"""Tool-calling chat providers for the agent. + +* `OpenRouterProvider` — OpenAI-compatible `/chat/completions` with `tools`. TEMPORARY for the prototype on + synthetic data: OpenRouter is a foreign service, forbidden for real PII (152-FZ art. 18 p. 5). Replace with + GigaChat (function calling) before any real patient — same `ToolChatProvider` contract. +* `MockAgentProvider` — offline, deterministic: plans tool calls from keywords and composes an answer from the + tool results. Keeps the demo working without network (ADR-004). +""" + +import json +import logging +import re +from typing import Any, Protocol + +import httpx +from pydantic import BaseModel, Field + +from app.config import Settings + +log = logging.getLogger(__name__) + + +class ToolCall(BaseModel): + id: str + name: str + arguments: dict[str, Any] = Field(default_factory=dict) + + +class AssistantTurn(BaseModel): + content: str | None = None + tool_calls: list[ToolCall] = Field(default_factory=list) + + +class ToolChatProvider(Protocol): + name: str + + async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: ... + + +class ProviderError(RuntimeError): + pass + + +class OpenRouterProvider: + name = "openrouter" + + def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None: + self._key = settings.openrouter_api_key + self._model = settings.openrouter_model + self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions" + self._timeout = settings.chat_agent_timeout_s + 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} + headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"} + try: + if self._client is not None: + resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout) + else: + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.post(self._url, json=body, headers=headers) + except httpx.HTTPError as exc: + raise ProviderError(type(exc).__name__) from exc + if resp.status_code != 200: + raise ProviderError(f"http {resp.status_code}") + try: + msg = resp.json()["choices"][0]["message"] + except (ValueError, KeyError, IndexError) as exc: + raise ProviderError("bad response") from exc + calls = [] + for c in msg.get("tool_calls") or []: + fn = c.get("function") or {} + try: + args = json.loads(fn.get("arguments") or "{}") + except json.JSONDecodeError: + args = {} + calls.append(ToolCall(id=c.get("id") or fn.get("name", "call"), name=fn.get("name", ""), arguments=args)) + usage = resp.json().get("usage") or {} + log.info("openrouter ok calls=%d tokens=%s", len(calls), usage.get("total_tokens")) + return AssistantTurn(content=msg.get("content"), tool_calls=calls) + + +# ------------------------------------------------------------------------------------------------ offline mock + +_DRUG_WORDS = re.compile( + r"\b(сертралин\w*|пароксетин\w*|эсциталопрам\w*|флуоксетин\w*|ибупрофен\w*|нурофен\w*|золофт\w*)", re.I +) +_MED_HINT = re.compile(r"(таблет|лекарств|препарат|состав|можно ли|совмест|вместе|противопоказ|аллерг|лактоз)", re.I) + + +class MockAgentProvider: + """Plans: record → (drug check for each mentioned drug, or the current prescription for medicine questions) + → guidelines search; then writes a short answer strictly from the tool results.""" + + name = "mock" + + async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantTurn: + question = next(m["content"] for m in reversed(messages) if m["role"] == "user") + done = [m for m in messages if m["role"] == "tool"] + if not done: + drugs = [m.group(1).lower() for m in _DRUG_WORDS.finditer(question)] + calls = [ToolCall(id="c0", name="get_patient_record")] + calls += [ + ToolCall(id=f"d{i}", name="check_drug_for_patient", arguments={"drug": d}) for i, d in enumerate(drugs) + ] + if drugs and re.search(r"состав|лактоз|желатин|аллерг|непереносим", question, re.I): + calls += [ + ToolCall(id=f"i{i}", name="get_drug_info", arguments={"drug": d}) for i, d in enumerate(drugs) + ] + calls.append(ToolCall(id="s0", name="search_guidelines", arguments={"query": question})) + return AssistantTurn(tool_calls=calls) + mine = re.search(r"мо(и|ими|их|ей|я|ё) (таблет|лекарств|препарат)", question, re.I) + checked = [m for m in done if m.get("name") == "check_drug_for_patient"] + if (not checked or mine) and len(checked) < 3 and _MED_HINT.search(question): + record = _tool_json(done, "get_patient_record") + rx = (record or {}).get("current_prescriptions") or [] + checked_args = {str(json.loads(m["content"]).get("inn", "")) for m in checked} + rx = [r for r in rx if not any(r["drug"].lower().startswith(c[:-1]) for c in checked_args if c)] + if rx: + return AssistantTurn( + tool_calls=[ToolCall(id="d9", name="check_drug_for_patient", arguments={"drug": rx[0]["drug"]})] + ) + return AssistantTurn(content=_compose(done)) + + +def _tool_json(done: list[dict[str, Any]], name: str) -> dict[str, Any] | None: + for m in done: + if m.get("name") == name: + try: + return json.loads(m["content"]) + except json.JSONDecodeError: + return None + return None + + +def _compose(done: list[dict[str, Any]]) -> str: + parts: list[str] = [] + seen: set[str] = set() + for m in done: + if m.get("name") != "check_drug_for_patient": + continue + data = json.loads(m["content"]) + if not data.get("found") or data["inn"] in seen: + continue + seen.add(data["inn"]) + findings = data.get("findings") or [] + if findings: + parts.append(f"По вашей карте и инструкции к препарату «{data['inn']}» есть важное:") + parts += [f"• {f['text']}" for f in findings[:3]] + else: + parts.append(f"В справочнике не нашлось особых ограничений по «{data['inn']}» с учётом вашей карты.") + for m in done: + if m.get("name") != "get_drug_info": + continue + data = json.loads(m["content"]) + comp = data.get("composition") if data.get("found") else None + if comp: + record = _tool_json(done, "get_patient_record") or {} + allergies = record.get("allergies") or [] + if allergies: + names = ", ".join(a["name"] for a in allergies) + hit = {t for a in allergies for t in a.get("tags", [])} & set(comp.get("excipient_tags", [])) + verdict = "это вещество есть" if hit else "этого вещества нет, но состав зависит от производителя" + parts.append(f"В вашей карте: {names}. В примере состава «{data['inn']}» {verdict}.") + excipients = ", ".join(comp["excipients"]) + parts.append(f"Состав ({comp['form']}): {comp['active']}; вспомогательные: {excipients}. {comp['note']}") + search = _tool_json(done, "search_guidelines") or {} + results = search.get("results") or [] + if results and not parts: # medicine findings already answer the question; a KR quote would dilute it + text = results[0]["text"].strip() + parts.append(f"В клинических рекомендациях: «{text[:400]}{'…' if len(text) > 400 else ''}»") + if not parts: + return "" + parts.append("Любые изменения в лечении обсуждайте с вашим врачом.") + return "\n".join(parts) diff --git a/app/llm/agent/tools.py b/app/llm/agent/tools.py new file mode 100644 index 0000000..0a17d0d --- /dev/null +++ b/app/llm/agent/tools.py @@ -0,0 +1,179 @@ +"""Read-only tools of the chat agent. Every tool is bound to the current patient's token on the server side: +the model never passes a token, so it cannot read another patient's data (medical secrecy, 323-FZ art. 13). + +Tool results contain clinical data only (no name, SNILS, phone) — they are sent to the LLM provider. +""" + +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +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 +from app.llm.rag.index import sentences + +log = logging.getLogger(__name__) + +RAG_TOP_K = 4 +PASSAGE_CHARS = 700 +# Patients never get dosages (SPEC §7): sentences with doses are dropped before the text reaches the LLM. +_DOSE = re.compile(r"\d\s*(?:-|–)?\s*\d*\s*(?:мг|mg|мкг|г/сут|таблетк)|\bдоз(?:а|у|ы|е|ой|ировк)", re.I) + +TOOL_SPECS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "search_guidelines", + "description": ( + "Поиск по базе знаний: клинические рекомендации Минздрава РФ (депрессия 2024, ПТСР 2023). " + "Возвращает фрагменты с источником и страницей. Используй для любых общих вопросов о состоянии, " + "лечении, сроках действия терапии, побочных эффектах, отмене, образе жизни." + ), + "parameters": { + "type": "object", + "properties": {"query": {"type": "string", "description": "Поисковый запрос на русском"}}, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_patient_record", + "description": ( + "Медицинская карта текущего пациента из ЕМИАС: диагнозы (МКБ-10), сопутствующие заболевания, " + "аллергии и непереносимости, назначения других врачей и текущее назначение психиатра. " + "Вызывай, когда ответ зависит от особенностей пациента или его лекарств." + ), + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "get_drug_info", + "description": ( + "Справка о препарате по инструкции ГРЛС: состав (действующее и вспомогательные вещества), " + "противопоказания, взаимодействия, беременность. Аргумент — название препарата (МНН или торговое)." + ), + "parameters": { + "type": "object", + "properties": {"drug": {"type": "string"}}, + "required": ["drug"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_drug_for_patient", + "description": ( + "Детерминированная проверка препарата для текущего пациента: сопутствующие заболевания, " + "непереносимости по составу, взаимодействия с другими его лекарствами. Используй перед любым " + "ответом о том, подходит ли пациенту препарат или сочетание препаратов." + ), + "parameters": { + "type": "object", + "properties": {"drug": {"type": "string"}}, + "required": ["drug"], + }, + }, + }, +] + + +@dataclass +class ToolContext: + token: str + sources: list[str] = field(default_factory=list) # citations collected for the answer footer + used: list[str] = field(default_factory=list) + _record: PatientRecord | None = None + _record_loaded: bool = False + + async def record(self) -> PatientRecord | None: + if not self._record_loaded: + self._record = await emias_records.get(self.token) + self._record_loaded = True + return self._record + + def cite(self, label: str) -> None: + if label and label not in self.sources: + self.sources.append(label) + + +async def run_tool(ctx: ToolContext, name: str, args: dict[str, Any]) -> str: + """Execute a tool; always returns a JSON string (errors included) so the model can recover.""" + ctx.used.append(name) + log.info("agent tool=%s", name) # no arguments: they may echo patient text + try: + if name == "search_guidelines": + return _json(_search(ctx, str(args.get("query", "")))) + if name == "get_patient_record": + rec = await ctx.record() + if rec is None: + return _json({"found": False}) + ctx.cite("ЕМИАС: медицинская карта") + return _json({"found": True, **rec.model_dump()}) + if name == "get_drug_info": + return _json(_drug_info(ctx, str(args.get("drug", "")))) + if name == "check_drug_for_patient": + return _json(await _check(ctx, str(args.get("drug", "")))) + return _json({"error": f"unknown tool {name}"}) + except Exception as exc: # noqa: BLE001 — a tool failure must not crash the chat + log.error("agent tool=%s failed: %s", name, type(exc).__name__) + return _json({"error": "tool failed"}) + + +def _search(ctx: ToolContext, query: str) -> dict[str, Any]: + hits = retrieve(query, audience="patient", top_k=RAG_TOP_K) if query.strip() else [] + out = [] + for h in hits: + label = h.chunk.citation.strip("[]") + ctx.cite(label) + text = " ".join(x for x in sentences(h.passage) if not _DOSE.search(x))[:PASSAGE_CHARS] + if text: + out.append({"source": label, "section": h.chunk.section, "text": text}) + return {"results": out, "found": bool(out)} + + +def _drug_info(ctx: ToolContext, name: str) -> dict[str, Any]: + entry = find_drug(name) + if entry is None: + return {"found": False, "drug": name} + ctx.cite(entry.source) + return { + "found": True, + "inn": entry.inn, + "class": entry.drug_class, + "composition": entry.composition.model_dump() if entry.composition else None, + "contraindications": entry.contraindications, + "interactions": [i.text for i in entry.interactions], + "pregnancy": entry.pregnancy, + "source": entry.source, + } + + +async def _check(ctx: ToolContext, name: str) -> dict[str, Any]: + entry = find_drug(name) + if entry is None: + return {"found": False, "drug": name, "note": "Препарата нет в справочнике — отвечать «уточните у врача»."} + rec = await ctx.record() + if rec is None: + return {"found": True, "inn": entry.inn, "record": False, "findings": []} + ctx.cite(entry.source) + ctx.cite("ЕМИАС: медицинская карта") + findings = check_patient(entry, rec, load_db()) + return { + "found": True, + "inn": entry.inn, + "record": True, + "findings": [f.model_dump() for f in findings], + "note": "Пустой список не означает, что препарат безопасен: решение о приёме принимает врач.", + } + + +def _json(data: Any) -> str: + return json.dumps(data, ensure_ascii=False) diff --git a/data/emias/records.yaml b/data/emias/records.yaml new file mode 100644 index 0000000..be4977e --- /dev/null +++ b/data/emias/records.yaml @@ -0,0 +1,92 @@ +# Мок электронной медицинской карты ЕМИАС (интеграция ЕМИАС/ЕГИСЗ — адаптер, SPEC §9). ТОЛЬКО СИНТЕТИКА. +# Ключ — синтетический СНИЛС из scripts/seed_demo.py; в рантайме превращается в токен через pseudo_id(), так что +# в чат-агента не попадают ни СНИЛС, ни ФИО: только клинические поля ниже. +# +# Поля: +# diagnoses — диагнозы МКБ-10 из карты (ставит врач; агент их не озвучивает как «диагноз», только учитывает) +# conditions — сопутствующие состояния и анамнез; `tags` сверяются с data/meds/drug_safety.yaml +# allergies — аллергии и непереносимости; `tags` сверяются с составом препаратов (вспомогательные вещества) +# other_medications — назначения других специалистов; `classes` сверяются со взаимодействиями +# pregnancy — для женщин: беременность/лактация (null — нет данных) +# Текущее психотропное назначение приходит из таблицы prescriptions (мок МИС, app/integrations/mis_mock.py). + +records: + "900-000-013 34": # Андрей С., 34, ветеран СВО — герой демо + diagnoses: + - {code: "F43.1", name: "Посттравматическое стрессовое расстройство"} + - {code: "F32.1", name: "Депрессивный эпизод средней степени"} + - {code: "T93.2", name: "Последствия осколочного ранения нижней конечности"} + conditions: + - {name: "Язвенная болезнь желудка в анамнезе (2019), ремиссия", tags: [ulcer_history]} + - {name: "Хронический болевой синдром после ранения", tags: [chronic_pain]} + allergies: + - {name: "Непереносимость лактозы", tags: [lactose]} + other_medications: + - {drug: "ибупрофен", how: "по требованию при боли", prescribed_by: "хирург", classes: [nsaid]} + pregnancy: null + + "900-000-041 01": # Дмитрий В., 41, ветеран + diagnoses: + - {code: "F43.1", name: "Посттравматическое стрессовое расстройство"} + conditions: + - {name: "Артериальная гипертензия", tags: [hypertension]} + allergies: [] + other_medications: + - {drug: "амлодипин", how: "ежедневно", prescribed_by: "терапевт", classes: [antihypertensive]} + pregnancy: null + + "900-000-037 02": # Сергей П., 37, ветеран + diagnoses: + - {code: "F43.1", name: "Посттравматическое стрессовое расстройство"} + conditions: + - {name: "Мигрень", tags: [migraine]} + allergies: [] + other_medications: + - {drug: "суматриптан", how: "при приступе мигрени", prescribed_by: "невролог", classes: [triptan]} + pregnancy: null + + "900-000-052 03": # Николай Ф., 52, ветеран + diagnoses: + - {code: "F43.1", name: "Посттравматическое стрессовое расстройство"} + - {code: "F10.1", name: "Пагубное употребление алкоголя"} + conditions: + - {name: "Фибрилляция предсердий", tags: [atrial_fibrillation]} + allergies: [] + other_medications: + - {drug: "варфарин", how: "ежедневно под контролем МНО", prescribed_by: "кардиолог", classes: [anticoagulant]} + pregnancy: null + + "900-000-023 04": # Анна Л., 23 + diagnoses: + - {code: "F32.1", name: "Депрессивный эпизод средней степени"} + conditions: [] + allergies: [] + other_medications: [] + pregnancy: {status: "не беременна, планирует беременность в течение года"} + + "900-000-034 05": # Ирина К., 34 + diagnoses: + - {code: "F32.1", name: "Депрессивный эпизод средней степени"} + conditions: [] + allergies: [] + other_medications: [] + pregnancy: null + + "900-000-029 06": # Ольга Н., 29 + diagnoses: + - {code: "F32.1", name: "Депрессивный эпизод средней степени"} + - {code: "F41.1", name: "Генерализованное тревожное расстройство"} + conditions: [] + allergies: [] + other_medications: [] + pregnancy: null + + "900-000-045 07": # Павел Р., 45 + diagnoses: + - {code: "F33.4", name: "Рекуррентное депрессивное расстройство, ремиссия"} + conditions: + - {name: "Эпилепсия, ремиссия 5 лет", tags: [epilepsy]} + allergies: [] + other_medications: + - {drug: "вальпроевая кислота", how: "ежедневно", prescribed_by: "невролог", classes: [anticonvulsant]} + pregnancy: null diff --git a/data/meds/drug_safety.yaml b/data/meds/drug_safety.yaml new file mode 100644 index 0000000..ee87bd4 --- /dev/null +++ b/data/meds/drug_safety.yaml @@ -0,0 +1,146 @@ +# Структурированный справочник безопасности препаратов для чат-агента (состав, противопоказания, взаимодействия). +# ПРОТОТИП: краткий пересказ инструкций по медицинскому применению (ГРЛС), сверить с действующей инструкцией и +# утвердить у психиатра (#8). Состав (вспомогательные вещества) зависит от производителя — здесь пример для +# формы из назначения; агент обязан это оговаривать. +# +# Теги совпадают с data/emias/records.yaml: +# condition tags (conditions[].tags) → cautions[].when_condition +# allergy tags (allergies[].tags) → composition.excipient_tags +# medicine class (other_medications.classes) → interactions[].with_class +# Детерминированная проверка — app/llm/agent/drug_safety.py; LLM только пересказывает её результат. + +common_ssri: &ssri + contraindications: + - "Повышенная чувствительность к компонентам препарата." + - "Одновременный приём ингибиторов МАО и 14 дней после их отмены (риск серотонинового синдрома)." + cautions: + - when_condition: ulcer_history + severity: high + text: "Язвенная болезнь в анамнезе: СИОЗС повышают риск желудочно-кишечных кровотечений, особенно вместе с НПВС или антикоагулянтами." + - when_condition: epilepsy + severity: medium + text: "Эпилепсия: препарат применяют с осторожностью, при появлении судорог его отменяет врач." + - when_condition: bipolar + severity: high + text: "Биполярное расстройство: возможен переход в маниакальную фазу, нужен контроль врача." + interactions: + - with_class: nsaid + severity: high + text: "НПВС (ибупрофен, диклофенак, кеторолак и др.) вместе с СИОЗС повышают риск кровотечений, прежде всего желудочно-кишечных." + - with_class: anticoagulant + severity: high + text: "Антикоагулянты (варфарин и др.): возрастает риск кровотечений, нужен более частый контроль свёртываемости." + - with_class: triptan + severity: medium + text: "Триптаны (суматриптан и др.): описан риск серотонинового синдрома, о сочетании должен знать врач." + - with_class: serotonergic_opioid + severity: high + text: "Трамадол и другие серотонинергические опиоиды: риск серотонинового синдрома и судорог." + - with_class: maoi + severity: high + text: "Ингибиторы МАО: сочетание противопоказано." + - with_class: alcohol + severity: medium + text: "Алкоголь во время лечения не рекомендуется: ухудшает сон и настроение, усиливает сонливость и головокружение." + pregnancy: "При беременности или её планировании решение о продолжении лечения принимает врач." + +drugs: + сертралин: + aliases: [sertraline, золофт, zoloft] + class: "антидепрессант, СИОЗС" + <<: *ssri + contraindications: + - "Повышенная чувствительность к компонентам препарата." + - "Одновременный приём ингибиторов МАО и 14 дней после их отмены." + - "Одновременный приём пимозида." + composition: + form: "таблетки, покрытые плёночной оболочкой" + active: "сертралина гидрохлорид" + excipients: [кальция гидрофосфат, целлюлоза микрокристаллическая, гипролоза, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80] + excipient_tags: [] + note: "Пример состава оригинального препарата; у дженериков состав может отличаться (в части из них есть лактоза) — сверить с инструкцией на упаковке." + source: "ГРЛС, инструкция по медицинскому применению сертралина (пересказ для прототипа)" + + пароксетин: + aliases: [paroxetine, паксил, paxil, рексетин] + class: "антидепрессант, СИОЗС" + <<: *ssri + contraindications: + - "Повышенная чувствительность к компонентам препарата." + - "Одновременный приём ингибиторов МАО и 14 дней после их отмены." + - "Одновременный приём тиоридазина или пимозида." + composition: + form: "таблетки, покрытые плёночной оболочкой" + active: "пароксетина гидрохлорида гемигидрат" + excipients: [кальция гидрофосфата дигидрат, карбоксиметилкрахмал натрия, магния стеарат, гипромеллоза, титана диоксид, макрогол, полисорбат 80] + excipient_tags: [] + note: "Пример состава; у дженериков возможны отличия (в том числе лактоза) — сверить с инструкцией на упаковке." + pregnancy: "При беременности или её планировании решение принимает врач; для пароксетина в инструкции отдельно указан риск для плода в первом триместре." + source: "ГРЛС, инструкция по медицинскому применению пароксетина (пересказ для прототипа)" + + эсциталопрам: + aliases: [escitalopram, ципралекс, cipralex, селектра] + class: "антидепрессант, СИОЗС" + <<: *ssri + contraindications: + - "Повышенная чувствительность к компонентам препарата." + - "Одновременный приём ингибиторов МАО и 14 дней после их отмены." + - "Удлинение интервала QT (врождённое или приобретённое) и приём препаратов, удлиняющих QT." + composition: + form: "таблетки, покрытые плёночной оболочкой" + active: "эсциталопрама оксалат" + excipients: [целлюлоза микрокристаллическая, кремния диоксид коллоидный, тальк, кроскармеллоза натрия, магния стеарат, гипромеллоза, макрогол, титана диоксид] + excipient_tags: [] + note: "Пример состава оригинального препарата; у дженериков состав может отличаться — сверить с инструкцией на упаковке." + source: "ГРЛС, инструкция по медицинскому применению эсциталопрама (пересказ для прототипа)" + + флуоксетин: + aliases: [fluoxetine, прозак, prozac, профлузак] + class: "антидепрессант, СИОЗС" + <<: *ssri + contraindications: + - "Повышенная чувствительность к компонентам препарата." + - "Одновременный приём ингибиторов МАО и 5 недель после отмены флуоксетина перед их назначением." + - "Одновременный приём тиоридазина или пимозида." + composition: + form: "капсулы" + active: "флуоксетина гидрохлорид" + excipients: [крахмал кукурузный, диметикон, желатин (оболочка капсулы), титана диоксид, красители] + excipient_tags: [gelatin] + note: "Пример состава; у разных производителей отличается (в части капсул есть лактоза) — сверить с инструкцией на упаковке." + source: "ГРЛС, инструкция по медицинскому применению флуоксетина (пересказ для прототипа)" + + ибупрофен: + aliases: [ibuprofen, нурофен, nurofen, миг] + class: "нестероидный противовоспалительный препарат (НПВС)" + classes: [nsaid] + contraindications: + - "Эрозивно-язвенные поражения желудка и кишечника в стадии обострения, желудочно-кишечное кровотечение." + - "Повышенная чувствительность к компонентам, «аспириновая» астма." + cautions: + - when_condition: ulcer_history + severity: high + text: "Язвенная болезнь в анамнезе: НПВС применяют с осторожностью из-за риска обострения и кровотечения." + interactions: + - with_class: ssri + severity: high + text: "Вместе с антидепрессантами группы СИОЗС повышается риск кровотечений." + - with_class: anticoagulant + severity: high + text: "С антикоагулянтами возрастает риск кровотечений." + composition: + form: "таблетки, покрытые оболочкой" + active: "ибупрофен" + excipients: [кроскармеллоза натрия, натрия лаурилсульфат, натрия цитрат, стеариновая кислота, кремния диоксид коллоидный, сахароза, тальк] + excipient_tags: [] + note: "Состав зависит от производителя — сверить с инструкцией на упаковке." + source: "ГРЛС, инструкция по медицинскому применению ибупрофена (пересказ для прототипа)" + +# Классы препаратов из назначений психиатра (для проверки «другой препарат × текущая терапия»). +drug_classes: + ssri: [сертралин, пароксетин, эсциталопрам, флуоксетин, циталопрам, флувоксамин] + +# Подписи тегов непереносимостей для ответа пациенту. +allergy_labels: + lactose: "лактоза" + gelatin: "желатин" diff --git a/tests/test_llm_agent.py b/tests/test_llm_agent.py new file mode 100644 index 0000000..8662dba --- /dev/null +++ b/tests/test_llm_agent.py @@ -0,0 +1,171 @@ +import json + +import httpx +import pytest + +from app.config import Settings +from app.core.privacy import pseudo_id, scrub +from app.core.types import ScrubbedText +from app.integrations.base import Prescription, integrations +from app.integrations.emias_record import emias_records +from app.llm import agent +from app.llm.agent.drug_safety import check_patient, find_drug +from app.llm.agent.providers import MockAgentProvider, OpenRouterProvider +from app.llm.agent.tools import ToolContext, run_tool + +ANDREY = pseudo_id("900-000-013 34") +ANNA = pseudo_id("900-000-023 04") +SERGEY = pseudo_id("900-000-037 02") + + +class _Rx: + def __init__(self, drug: str) -> None: + self.drug = drug + + async def get_prescriptions(self, token: str) -> list[Prescription]: + return [Prescription(drug=self.drug, dose="50 мг", instructions="утром после еды")] + + +@pytest.fixture +def sertraline(monkeypatch): + monkeypatch.setattr(integrations, "prescriptions", _Rx("сертралин")) + + +# ----------------------------------------------------------------------------------------------- drug safety + + +@pytest.mark.parametrize("name", ["сертралин", "сертралине", "Золофт", "сертралин 50 мг", "sertraline"]) +def test_find_drug_forms(name): + assert find_drug(name).inn == "сертралин" + + +def test_find_drug_unknown(): + assert find_drug("аспирин") is None + + +async def test_andrey_sertraline_findings(sertraline): + rec = await emias_records.get(ANDREY) + findings = check_patient(find_drug("сертралин"), rec) + kinds = {(f.kind, f.severity) for f in findings} + assert ("condition", "high") in kinds # ulcer history + assert any(f.kind == "interaction" and "ибупрофен" in f.text for f in findings) + + +async def test_ibuprofen_sees_current_ssri(sertraline): + rec = await emias_records.get(ANDREY) + findings = check_patient(find_drug("ибупрофен"), rec) + assert any("СИОЗС" in f.text and "сертралин" in f.text for f in findings) + + +async def test_pregnancy_and_triptan(monkeypatch): + monkeypatch.setattr(integrations, "prescriptions", _Rx("флуоксетин")) + anna = await emias_records.get(ANNA) + assert any(f.kind == "pregnancy" for f in check_patient(find_drug("флуоксетин"), anna)) + monkeypatch.setattr(integrations, "prescriptions", _Rx("пароксетин")) + sergey = await emias_records.get(SERGEY) + assert any("Триптаны" in f.text for f in check_patient(find_drug("пароксетин"), sergey)) + + +# ----------------------------------------------------------------------------------------------------- tools + + +async def test_record_tool_has_no_identifiers(sertraline): + out = await run_tool(ToolContext(token=ANDREY), "get_patient_record", {}) + data = json.loads(out) + assert data["found"] and data["current_prescriptions"][0]["drug"] == "сертралин" + assert "900-000" not in out and "Андрей" not in out + + +async def test_search_tool_drops_doses(): + out = json.loads(await run_tool(ToolContext(token=ANDREY), "search_guidelines", {"query": "дозы СИОЗС при ПТСР"})) + for r in out["results"]: + assert " мг" not in r["text"] + + +async def test_unknown_tool_is_json_error(): + assert "error" in json.loads(await run_tool(ToolContext(token=ANDREY), "rm_rf", {})) + + +# ------------------------------------------------------------------------------------------------ agent loop + + +def _settings(**kw) -> Settings: + base = {"openrouter_api_key": "test", "chat_agent": "openrouter", "chat_agent_timeout_s": 5.0} + return Settings(_env_file=None, **{**base, **kw}) + + +def _fake_openrouter(final_text: str, calls: list[dict]) -> tuple[OpenRouterProvider, list[dict]]: + sent: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + sent.append(body) + if len(sent) == 1: + msg = {"role": "assistant", "content": None, "tool_calls": calls} + else: + msg = {"role": "assistant", "content": final_text} + return httpx.Response(200, json={"choices": [{"message": msg}], "usage": {"total_tokens": 10}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return OpenRouterProvider(_settings(), client=client), sent + + +_CALLS = [ + {"id": "a", "type": "function", "function": {"name": "get_patient_record", "arguments": "{}"}}, + { + "id": "b", + "type": "function", + "function": {"name": "check_drug_for_patient", "arguments": '{"drug": "ибупрофен"}'}, + }, +] + + +async def test_agent_openrouter_tool_loop(sertraline): + text = ( + "В вашей карте отмечена язвенная болезнь, а ибупрофен с сертралином повышает риск кровотечений. " + "Обсудите с врачом." + ) + provider, sent = _fake_openrouter(text, _CALLS) + ans = await agent.answer_chat( + ANDREY, scrub("Можно ибупрофен? Мой телефон 89161234567"), provider=provider, settings=_settings() + ) + assert ans is not None and ans.text == text + assert ans.tools == ["get_patient_record", "check_drug_for_patient"] + assert any("ЕМИАС" in s for s in ans.sources) + first, second = sent + assert "89161234567" not in json.dumps(first, ensure_ascii=False) # scrubbed before the provider + tool_msgs = [m for m in second["messages"] if m["role"] == "tool"] + assert len(tool_msgs) == 2 and all("name" not in m for m in tool_msgs) + assert first["tools"] and first["model"] + + +async def test_forbidden_output_rejected(sertraline): + provider, _ = _fake_openrouter("Увеличьте дозу сертралина вдвое.", _CALLS) + assert await agent.answer_chat(ANDREY, scrub("что делать"), provider=provider, settings=_settings()) is None + + +async def test_provider_error_returns_none(): + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500))) + provider = OpenRouterProvider(_settings(), client=client) + assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None + + +async def test_unscrubbed_input_never_sent(): + provider, sent = _fake_openrouter("ok", []) + assert await agent.answer_chat(ANDREY, ScrubbedText("звоните 8 916 123-45-67"), provider=provider) is None + assert sent == [] + + +async def test_mock_agent_answers_offline(sertraline): + ans = await agent.answer_chat( + ANDREY, scrub("Можно ли мне ибупрофен вместе с моими таблетками?"), provider=MockAgentProvider() + ) + assert ans is not None and "кровотеч" in ans.text and ans.provider == "mock" + + +def test_build_provider_modes(): + assert agent.build_provider(Settings(_env_file=None, chat_agent="auto")) is None + assert isinstance(agent.build_provider(_settings(chat_agent="auto")), OpenRouterProvider) + 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