Spaces:
Sleeping
Sleeping
| """Cliente Claude (ruta h铆brida opcional y juez de evals). | |
| Usa salida estructurada v铆a 'tool use': se define una herramienta cuyo input_schema es | |
| el JSON Schema de InterpretacionClinica y se fuerza su uso, de modo que el modelo | |
| devuelve directamente un objeto que valida contra Pydantic. Sin regex de limpieza. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from ..config import obtener_config | |
| from ..schemas import InterpretacionClinica, esquema_estructurado | |
| from .base import ErrorModelo | |
| _HERRAMIENTA = { | |
| "name": "emitir_interpretacion", | |
| "description": "Emite la interpretaci贸n cl铆nica veterinaria en formato estructurado.", | |
| "input_schema": esquema_estructurado(), | |
| } | |
| _DATA_URL = re.compile(r"^data:(image/(?:jpeg|png|gif|webp));base64,(.+)$", re.DOTALL) | |
| def _bloques_imagen(imagenes: list[str]) -> list[dict]: | |
| bloques = [] | |
| for img in imagenes: | |
| m = _DATA_URL.match(img) | |
| if not m: | |
| continue | |
| bloques.append( | |
| { | |
| "type": "image", | |
| "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}, | |
| } | |
| ) | |
| return bloques | |
| class ClaudeClient: | |
| nombre = "claude" | |
| prosa = False | |
| def __init__(self) -> None: | |
| cfg = obtener_config() | |
| if not cfg.anthropic_api_key: | |
| raise ErrorModelo( | |
| "ANTHROPIC_API_KEY no configurada para la ruta Claude.", reintentable=False | |
| ) | |
| # Import perezoso para no exigir el SDK cuando s贸lo se usa medGemma. | |
| from anthropic import AsyncAnthropic | |
| self._cliente = AsyncAnthropic(api_key=cfg.anthropic_api_key) | |
| self._modelo = cfg.claude_model | |
| self.modelo = cfg.claude_model | |
| async def interpretar( | |
| self, sistema: str, mensaje_usuario: str, imagenes: list[str] | |
| ) -> InterpretacionClinica: | |
| contenido: list[dict] = [*_bloques_imagen(imagenes), {"type": "text", "text": mensaje_usuario}] | |
| try: | |
| resp = await self._cliente.messages.create( | |
| model=self._modelo, | |
| max_tokens=1500, | |
| system=sistema, | |
| messages=[{"role": "user", "content": contenido}], | |
| tools=[_HERRAMIENTA], | |
| tool_choice={"type": "tool", "name": "emitir_interpretacion"}, | |
| ) | |
| except Exception as exc: # noqa: BLE001 | |
| raise ErrorModelo(f"Fallo llamando a Claude: {exc}") from exc | |
| # Los clasificadores de seguridad pueden rechazar la petici贸n: llega un HTTP 200 con | |
| # `stop_reason="refusal"` y `content` vac铆o o parcial. Sin esta comprobaci贸n el bucle de | |
| # abajo no encuentra el bloque tool_use y el usuario recibe un error enga帽oso. | |
| if resp.stop_reason == "refusal": | |
| categoria = getattr(getattr(resp, "stop_details", None), "category", None) | |
| raise ErrorModelo( | |
| "El modelo rechaz贸 la petici贸n por sus filtros de seguridad" | |
| + (f" (categor铆a: {categoria})" if categoria else "") | |
| + ". Reformula el caso o usa la ruta medGemma.", | |
| reintentable=False, | |
| ) | |
| for bloque in resp.content: | |
| if getattr(bloque, "type", None) == "tool_use": | |
| try: | |
| return InterpretacionClinica.model_validate(bloque.input) | |
| except Exception as exc: # noqa: BLE001 | |
| raise ErrorModelo(f"Salida de Claude no valida el esquema: {exc}") from exc | |
| raise ErrorModelo("Claude no devolvi贸 el bloque tool_use esperado.") | |
| async def juzgar(self, sistema: str, mensaje: str) -> dict: | |
| """Utilidad para el juez de evals: devuelve JSON arbitrario del modelo.""" | |
| resp = await self._cliente.messages.create( | |
| model=self._modelo, | |
| max_tokens=1200, | |
| system=sistema, | |
| messages=[{"role": "user", "content": mensaje}], | |
| ) | |
| if resp.stop_reason == "refusal": | |
| raise ErrorModelo( | |
| "El juez rechaz贸 el caso por sus filtros de seguridad.", reintentable=False | |
| ) | |
| texto = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") | |
| try: | |
| return json.loads(texto) | |
| except json.JSONDecodeError as exc: | |
| # Un juez que devuelve prosa en vez de JSON debe fallar con un error tipado, no con | |
| # un JSONDecodeError crudo que el arn茅s de evals no distingue de un fallo de red. | |
| raise ErrorModelo(f"El juez no devolvi贸 JSON v谩lido: {exc}") from exc | |