File size: 4,604 Bytes
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
70e641d
 
 
 
 
bf9f7d1
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab7946
70e641d
 
 
 
a5d73ff
 
 
70e641d
 
 
 
 
6ab7946
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5d73ff
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5d73ff
 
 
70e641d
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
"""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