Cuaderno/Proyectos/8 · Asistente de laboratorio con LLM
Proyecto 8 de 10 · Bloque 3

Asistente de laboratorio con LLM

Un endpoint que recibe una pregunta sobre un protocolo y responde usando la API de Claude. Hasta ahora, misma entrada → misma salida. Un LLM no: es una función probabilística que cuesta dinero por token. Eso cambia cómo se prueba, cómo se controla y cómo se presenta.

concepto · el LLM como función no determinista con costo4 sesionesanthropic · pydantic · evals · prompts versionados
ConstruyesPOST /preguntar + un set de evaluación de 20 casos
AprendesEvaluar con números, no con una pregunta de ejemplo; medir tokens y costo
CostoTests y evals falsos: $0. Contra la API real: centavos por corrida.
Terminas conUn cambio de prompt decidido con datos, anotado en DECISIONES.md

Objetivos

Sigues dentro del repo inventario-lab. Al terminar vas a haber hecho:

  • Una función responder(pregunta, contexto) que llama a la API de Claude con salida estructurada (JSON validado con Pydantic) y devuelve también tokens y costo.
  • Un prompt que vive en un archivo versionado, no dentro del código.
  • Tests que no llaman al modelo real: inyectan un cliente falso.
  • Un set de 20 preguntas con respuesta conocida y un comando que reporta % correcto y costo total.
  • Un cambio de prompt decidido comparando números de antes y después.
El cambio de mentalidad

Todo lo que construiste hasta el P7 es determinista: si el test pasa hoy, pasa mañana. Un LLM se equivoca a veces, se equivoca distinto cada vez, y lo hace con total seguridad. No puedes escribir assert respuesta == "56 °C"; puedes medir en cuántos de 20 casos conocidos acierta, y decidir con ese número. Es exactamente la lógica de validar un ensayo: no un tubo, una corrida completa con controles.

Temas que vas a usar

Antes de empezar

Necesitas el repo inventario-lab con la API del P4 funcionando (los proyectos 5–7 no son requisito técnico de este). Y una cuenta de la API de Anthropic.

1. La API key

Crea una cuenta en console.anthropic.com, carga un mínimo de crédito (5 USD sobran para todo el proyecto) y genera una API key. La key va en tu .env local — que ya está en .gitignore desde el P6 — y nunca en el código ni en el repo:

.env (local, NO se commitea)
ANTHROPIC_API_KEY=sk-ant-api03-...

Agrega la línea (sin el valor) a .env.example, que sí se commitea.

2. Dependencias

git switch -c feat/asistente-llm
uv add anthropic
Deberías ver
Resolved … packages
 + anthropic==0.6x.x
 …

3. El modelo y su precio

Vas a usar claude-sonnet-5. A agosto de 2026, su precio de lista es $3 por millón de tokens de entrada y $15 por millón de salida (hay precio introductorio de $2/$10 hasta fin de agosto de 2026; en el código usamos el de lista, el peor caso). Un token es el pedazo de texto que el modelo procesa: en español, más o menos ¾ de palabra. El precio cambia con el tiempo y por modelo: verifícalo en la documentación oficial antes de fijarlo en tu código.

Antes de escribir una línea

Piensa a dónde van tus datos. Todo lo que pongas en el prompt viaja a un servidor de Anthropic. Un protocolo genérico de extracción de ADN no es problema; datos de pacientes, secuencias confidenciales o resultados no publicados de tu trabajo sí pueden serlo. La regla: al prompt va el mínimo necesario, y nada que no puedas justificar ante tu jefa.

Construcción paso a paso

El orden importa: primero el material (protocolo y prompt), luego la función, luego los tests, y recién al final la API real. Vas a poder construir y probar el 90% del proyecto sin gastar un centavo.

Paso 1 El protocolo de ejemplo

El asistente responde sobre un protocolo que le pasas como contexto. Usa uno real de tu trabajo si puedes compartirlo; si no, escribe uno realista. Este es el que usa la guía (recortado; escríbelo completo con 18 pasos):

data/protocolo-extraccion-adn.md
# Protocolo: extracción de ADN genómico por columna de sílice

Material de partida: 200 µL de sangre total con EDTA, o hasta 25 mg de tejido.

## Lisis
- Paso 3. Pipetear 20 µL de proteinasa K en un tubo de 1,5 mL.
- Paso 4. Agregar 200 µL de muestra y 200 µL de buffer AL. Mezclar con
  vórtex durante 15 segundos. No agregar la proteinasa K directamente al
  buffer AL.
- Paso 5. Incubar a 56 °C durante 10 minutos en el baño seco.
…
## Elución
- Paso 14. Agregar 100 µL de buffer AE al centro de la membrana. Incubar a
  temperatura ambiente durante 5 minutos.
…
- Paso 18. Almacenar el ADN a 4 °C si se usará en la semana, o a -20 °C
  para almacenamiento prolongado.
Por qué pasos numerados. El asistente va a citar su fuente ("Paso 5"). Sin estructura citables no hay cita, y sin cita no puedes verificar. Esto se vuelve central en el P9.

Paso 2 El prompt es código: vive en un archivo

El prompt define el comportamiento del asistente igual que una función define un cálculo. Cambiar una palabra puede cambiar todo. Por eso se versiona como cualquier archivo del repo, no se esconde en un string dentro del código:

src/inventario/prompts/asistente.md
Eres el asistente de un laboratorio de biología molecular. Respondes preguntas
sobre el protocolo que se te entrega dentro de la etiqueta <protocolo>.

Reglas:
- Responde SOLO con información que esté en el protocolo. No inventes pasos,
  tiempos, volúmenes ni temperaturas.
- Si la respuesta no está en el protocolo, dilo explícitamente y usa
  confianza "baja" y fuente null.
- "fuente" es el número o título del paso del protocolo del que sale la
  respuesta (por ejemplo "Paso 4").
- "confianza" es "alta" si la respuesta está literal en el protocolo,
  "media" si requiere interpretación, "baja" si no está.
- Sé breve: una o dos frases.
Cada regla existe por un fallo que previene. "No inventes" ataca el fallo clásico de los LLM: rellenar con seguridad lo que no saben. "Dilo explícitamente" hace medible el caso "no está en el protocolo" (caso 20 de tus evals). "Sé breve" controla tokens de salida, que son los caros ($15/M contra $3/M).

Paso 3 La función responder()

Una función pura desde afuera: entra pregunta y contexto, sale una Respuesta con texto, confianza, fuente, tokens y costo. El cliente de la API se puede inyectar, y esa decisión es la que hace todo lo demás testeable:

src/inventario/asistente.py
"""Asistente de laboratorio: responde preguntas sobre un protocolo usando la API de Claude."""

import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ValidationError

# Precio de lista de claude-sonnet-5 (USD por token), agosto 2026.
# Si cambias de modelo, cambia también esto: el costo es parte del contrato.
PRECIO_INPUT_USD = 3.00 / 1_000_000
PRECIO_OUTPUT_USD = 15.00 / 1_000_000

MODELO_DEFAULT = "claude-sonnet-5"
MAX_TOKENS = 500

_PROMPT_PATH = Path(__file__).parent / "prompts" / "asistente.md"

# El esquema que el modelo DEBE cumplir (salida estructurada).
SCHEMA = {
    "type": "object",
    "properties": {
        "respuesta": {"type": "string"},
        "confianza": {"type": "string", "enum": ["alta", "media", "baja"]},
        "fuente": {"type": ["string", "null"]},
    },
    "required": ["respuesta", "confianza", "fuente"],
    "additionalProperties": False,
}


class RespuestaJSON(BaseModel):
    """Validación del JSON que devuelve el modelo."""

    respuesta: str
    confianza: Literal["alta", "media", "baja"]
    fuente: str | None


@dataclass(frozen=True)
class Respuesta:
    """Lo que devuelve el asistente: la respuesta y su costo."""

    texto: str
    confianza: str
    fuente: str | None
    tokens_in: int
    tokens_out: int
    costo_usd: float


class AsistenteError(Exception):
    """Error base del asistente."""


class FormatoInvalido(AsistenteError):
    """El modelo no devolvió el JSON acordado."""


class RespuestaIncompleta(AsistenteError):
    """El modelo se quedó sin tokens antes de terminar (stop_reason=max_tokens)."""


def cargar_prompt() -> str:
    """El prompt vive en un archivo versionado, no en el código."""
    return _PROMPT_PATH.read_text(encoding="utf-8")


def responder(pregunta: str, contexto: str, cliente=None) -> Respuesta:
    """Pregunta al modelo sobre el protocolo dado.

    `cliente` se inyecta en los tests (cliente falso). En producción se crea
    el cliente real de Anthropic, que lee ANTHROPIC_API_KEY del entorno.
    """
    if not pregunta.strip():
        raise ValueError("la pregunta está vacía")
    if cliente is None:
        import anthropic

        cliente = anthropic.Anthropic(timeout=30.0)

    modelo = os.environ.get("ASISTENTE_MODEL", MODELO_DEFAULT)
    respuesta_api = cliente.messages.create(
        model=modelo,
        max_tokens=MAX_TOKENS,
        system=cargar_prompt(),
        output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
        messages=[
            {
                "role": "user",
                "content": f"<protocolo>\n{contexto}\n</protocolo>\n\nPregunta: {pregunta}",
            }
        ],
    )

    if respuesta_api.stop_reason == "max_tokens":
        raise RespuestaIncompleta(
            f"el modelo agotó los {MAX_TOKENS} tokens; la respuesta llegó cortada"
        )

    texto_crudo = next(
        (bloque.text for bloque in respuesta_api.content if bloque.type == "text"), ""
    )
    try:
        datos = RespuestaJSON.model_validate(json.loads(texto_crudo))
    except (json.JSONDecodeError, ValidationError) as err:
        raise FormatoInvalido(f"el modelo no devolvió el JSON acordado: {texto_crudo!r}") from err

    tokens_in = respuesta_api.usage.input_tokens
    tokens_out = respuesta_api.usage.output_tokens
    return Respuesta(
        texto=datos.respuesta,
        confianza=datos.confianza,
        fuente=datos.fuente,
        tokens_in=tokens_in,
        tokens_out=tokens_out,
        costo_usd=tokens_in * PRECIO_INPUT_USD + tokens_out * PRECIO_OUTPUT_USD,
    )

Lo que esta función encierra, uno por uno, está en Entiende el código. Fíjate desde ya en tres límites que nunca se negocian: max_tokens (tope de gasto por respuesta), timeout (el modelo tarda segundos, no dejas la request colgada para siempre) y que no hay ningún bucle que llame al modelo sin tope.

Paso 4 Tests con cliente falso: cero llamadas reales

Los tests no llaman al modelo. Le inyectas a responder() un objeto que se hace pasar por el cliente de Anthropic y devuelve lo que tú decidas. Así pruebas tu lógica (parseo, validación, costos, errores) con la velocidad y el costo de siempre:

tests/conftest.py
import json
from types import SimpleNamespace

import pytest


def hacer_cliente_falso(cuerpo: dict | str, stop_reason: str = "end_turn"):
    """Cliente que devuelve siempre el mismo cuerpo. Registra la última llamada."""
    texto = cuerpo if isinstance(cuerpo, str) else json.dumps(cuerpo, ensure_ascii=False)
    llamadas = []

    def create(**kwargs):
        llamadas.append(kwargs)
        return SimpleNamespace(
            stop_reason=stop_reason,
            content=[SimpleNamespace(type="text", text=texto)],
            usage=SimpleNamespace(input_tokens=1000, output_tokens=100),
        )

    cliente = SimpleNamespace(messages=SimpleNamespace(create=create))
    cliente.llamadas = llamadas
    return cliente


@pytest.fixture
def cliente_ok():
    return hacer_cliente_falso(
        {"respuesta": "Se incuba a 56 °C.", "confianza": "alta", "fuente": "Paso 5"}
    )
tests/test_asistente.py
import pytest

from inventario.asistente import (
    PRECIO_INPUT_USD,
    PRECIO_OUTPUT_USD,
    FormatoInvalido,
    RespuestaIncompleta,
    responder,
)
from tests.conftest import hacer_cliente_falso

CONTEXTO = "Paso 5. Incubar a 56 °C durante 10 minutos."


def test_responder_devuelve_respuesta_estructurada(cliente_ok):
    r = responder("¿A qué temperatura se incuba?", CONTEXTO, cliente=cliente_ok)
    assert r.texto == "Se incuba a 56 °C."
    assert r.confianza == "alta"
    assert r.fuente == "Paso 5"


def test_responder_calcula_el_costo(cliente_ok):
    r = responder("¿A qué temperatura se incuba?", CONTEXTO, cliente=cliente_ok)
    assert r.tokens_in == 1000
    assert r.tokens_out == 100
    assert r.costo_usd == pytest.approx(1000 * PRECIO_INPUT_USD + 100 * PRECIO_OUTPUT_USD)


def test_responder_manda_el_protocolo_y_el_prompt(cliente_ok):
    responder("¿Cuánto dura?", CONTEXTO, cliente=cliente_ok)
    llamada = cliente_ok.llamadas[0]
    assert CONTEXTO in llamada["messages"][0]["content"]
    assert "No inventes" in llamada["system"]
    assert llamada["output_config"]["format"]["type"] == "json_schema"


def test_pregunta_vacia_lanza_error(cliente_ok):
    with pytest.raises(ValueError):
        responder("   ", CONTEXTO, cliente=cliente_ok)


def test_json_invalido_lanza_formato_invalido():
    cliente = hacer_cliente_falso("esto no es JSON")
    with pytest.raises(FormatoInvalido):
        responder("¿Cuánto dura?", CONTEXTO, cliente=cliente)


def test_confianza_fuera_del_enum_lanza_formato_invalido():
    cliente = hacer_cliente_falso(
        {"respuesta": "56 °C", "confianza": "altisima", "fuente": None}
    )
    with pytest.raises(FormatoInvalido):
        responder("¿Cuánto dura?", CONTEXTO, cliente=cliente)


def test_max_tokens_lanza_respuesta_incompleta():
    cliente = hacer_cliente_falso(
        {"respuesta": "cortada", "confianza": "alta", "fuente": None},
        stop_reason="max_tokens",
    )
    with pytest.raises(RespuestaIncompleta):
        responder("¿Cuánto dura?", CONTEXTO, cliente=cliente)

Crea también tests/__init__.py vacío (para que from tests.conftest import … resuelva) y corre:

uv run pytest -v
Deberías ver
tests/test_asistente.py::test_responder_devuelve_respuesta_estructurada PASSED
tests/test_asistente.py::test_responder_calcula_el_costo PASSED
tests/test_asistente.py::test_responder_manda_el_protocolo_y_el_prompt PASSED
tests/test_asistente.py::test_pregunta_vacia_lanza_error PASSED
tests/test_asistente.py::test_json_invalido_lanza_formato_invalido PASSED
tests/test_asistente.py::test_confianza_fuera_del_enum_lanza_formato_invalido PASSED
tests/test_asistente.py::test_max_tokens_lanza_respuesta_incompleta PASSED

============================== 7 passed in 0.08s ===============================
Qué acabas de probar sin gastar. Que armas bien la llamada (protocolo y prompt viajan), que parseas y validas la respuesta, que el costo se calcula, y que cada forma de fallar del modelo (JSON roto, enum inválido, respuesta cortada) tiene su excepción con nombre. Lo único que NO probaste es la calidad de las respuestas del modelo: para eso son los evals del paso 6.
git add . && git commit -m "Asistente con salida estructurada, testeado con cliente falso"

Paso 5 El endpoint POST /preguntar

La misma mecánica del P4: el router no sabe de LLMs, solo traduce HTTP ↔ dominio. La dependencia get_cliente_llm existe para que los tests puedan inyectar el falso con dependency_overrides, igual que hiciste con la DB en memoria:

src/inventario/api/main.py (agregar)
from pathlib import Path
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel

from inventario.asistente import AsistenteError, Respuesta, responder

RAIZ = Path(__file__).resolve().parent.parent.parent.parent
PROTOCOLO = RAIZ / "data" / "protocolo-extraccion-adn.md"


def get_cliente_llm():
    """En producción devuelve None (asistente crea el cliente real).

    En tests se sobreescribe con dependency_overrides para inyectar el falso.
    """
    return


class PreguntaIn(BaseModel):
    pregunta: str


class PreguntaOut(BaseModel):
    respuesta: str
    confianza: str
    fuente: str | None
    tokens_in: int
    tokens_out: int
    costo_usd: float


@app.post("/preguntar", response_model=PreguntaOut)
def preguntar(
    datos: PreguntaIn, cliente: Annotated[object, Depends(get_cliente_llm)]
) -> PreguntaOut:
    contexto = PROTOCOLO.read_text(encoding="utf-8")
    try:
        r: Respuesta = responder(datos.pregunta, contexto, cliente=cliente)
    except ValueError as err:
        raise HTTPException(status_code=422, detail=str(err)) from err
    except AsistenteError as err:
        # El modelo falló (formato, tokens): no es culpa del cliente -> 502
        raise HTTPException(status_code=502, detail=str(err)) from err
    return PreguntaOut(
        respuesta=r.texto,
        confianza=r.confianza,
        fuente=r.fuente,
        tokens_in=r.tokens_in,
        tokens_out=r.tokens_out,
        costo_usd=r.costo_usd,
    )
tests/test_api.py (agregar)
from fastapi.testclient import TestClient

from inventario.api.main import app, get_cliente_llm
from tests.conftest import hacer_cliente_falso


def cliente_de_prueba(cuerpo):
    falso = hacer_cliente_falso(cuerpo)
    app.dependency_overrides[get_cliente_llm] = lambda: falso
    return TestClient(app)


def test_preguntar_feliz():
    tc = cliente_de_prueba(
        {"respuesta": "Se incuba a 56 °C.", "confianza": "alta", "fuente": "Paso 5"}
    )
    r = tc.post("/preguntar", json={"pregunta": "¿A qué temperatura se incuba la lisis?"})
    assert r.status_code == 200
    datos = r.json()
    assert datos["respuesta"] == "Se incuba a 56 °C."
    assert datos["fuente"] == "Paso 5"
    assert datos["costo_usd"] > 0


def test_preguntar_cuerpo_invalido_es_422():
    tc = cliente_de_prueba({"respuesta": "x", "confianza": "alta", "fuente": None})
    r = tc.post("/preguntar", json={})
    assert r.status_code == 422


def test_modelo_rompe_el_formato_es_502():
    tc = cliente_de_prueba("no soy JSON")
    r = tc.post("/preguntar", json={"pregunta": "¿Cuánto dura la lisis?"})
    assert r.status_code == 502
uv run pytest -q
Deberías ver
10 passed in 0.11s
Por qué 502 y no 500. Cuando el modelo rompe el contrato (JSON inválido, respuesta cortada), tu servidor no está roto: el servicio del que depende falló. 502 Bad Gateway dice exactamente eso, y en tus logs vas a distinguir de un vistazo "mi bug" de "el modelo se portó mal".

Paso 6 El set de evaluación: 20 preguntas con respuesta conocida

Aquí está el corazón del proyecto. Escribes 20 preguntas sobre tu protocolo de las que sabes la respuesta, con las palabras clave que debe contener una respuesta correcta. Incluye una que no está en el protocolo (la 20): el asistente debe decir que no sabe, no inventar:

evals/casos.json (recortado; escribe los 20)
[
  {"id": 1,  "pregunta": "¿A qué temperatura se incuba la lisis?",
   "esperado": ["56"], "fuente_esperada": "Paso 5"},
  {"id": 2,  "pregunta": "¿Cuánto tiempo dura la incubación de lisis?",
   "esperado": ["10 min"], "fuente_esperada": "Paso 5"},
  {"id": 5,  "pregunta": "¿Se puede agregar la proteinasa K directamente al buffer AL?",
   "esperado": ["no"], "fuente_esperada": "Paso 4"},
  {"id": 16, "pregunta": "¿Qué relación A260/A280 indica ADN limpio?",
   "esperado": ["1,7", "1.7"], "fuente_esperada": "Paso 17"},
  {"id": 20, "pregunta": "¿Qué primers uso para amplificar el gen 16S?",
   "esperado": ["no está", "no aparece", "no lo indica", "no se indica", "no figura"],
   "fuente_esperada": null}
]

Y el corredor de la evaluación. Tiene un modo --fake que responde desde los propios casos: no mide al modelo (siempre da 100%), mide que tu tubería completa funciona antes de gastar:

src/inventario/evals.py
"""Evaluación del asistente: 20 preguntas con respuesta conocida.

Uso:
    uv run python -m inventario.evals          # contra la API real (necesita ANTHROPIC_API_KEY)
    uv run python -m inventario.evals --fake   # cliente falso: prueba la tubería sin gastar

Imprime el % de respuestas correctas y el costo total. Antes de cambiar el
prompt, corre esto y anota los números; después de cambiarlo, corre de nuevo.
Se decide con números, no con una pregunta de ejemplo.
"""

import argparse
import json
import sys
from pathlib import Path
from types import SimpleNamespace

from inventario.asistente import AsistenteError, responder

RAIZ = Path(__file__).resolve().parent.parent.parent
CASOS = RAIZ / "evals" / "casos.json"
PROTOCOLO = RAIZ / "data" / "protocolo-extraccion-adn.md"


class ClienteFalso:
    """Responde desde el propio caso: sirve para probar la tubería, no el modelo."""

    def __init__(self, casos: list[dict]):
        self._por_pregunta = {c["pregunta"]: c for c in casos}
        self.messages = SimpleNamespace(create=self._create)

    def _create(self, **kwargs):
        contenido_usuario = kwargs["messages"][0]["content"]
        caso = next(
            (c for p, c in self._por_pregunta.items() if p in contenido_usuario),
            None,
        )
        if caso is None:
            cuerpo = {"respuesta": "No está en el protocolo.", "confianza": "baja", "fuente": None}
        else:
            cuerpo = {
                "respuesta": f"Según el protocolo: {caso['esperado'][0]}.",
                "confianza": "alta" if caso["fuente_esperada"] else "baja",
                "fuente": caso["fuente_esperada"],
            }
        return SimpleNamespace(
            stop_reason="end_turn",
            content=[SimpleNamespace(type="text", text=json.dumps(cuerpo, ensure_ascii=False))],
            usage=SimpleNamespace(input_tokens=650, output_tokens=60),
        )


def es_correcta(texto: str, esperado: list[str]) -> bool:
    """Correcta si alguna de las palabras clave esperadas aparece en la respuesta."""
    texto = texto.lower()
    return any(clave.lower() in texto for clave in esperado)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--fake", action="store_true", help="usa el cliente falso (sin costo)")
    args = parser.parse_args()

    casos = json.loads(CASOS.read_text(encoding="utf-8"))
    contexto = PROTOCOLO.read_text(encoding="utf-8")
    cliente = ClienteFalso(casos) if args.fake else None

    correctas = 0
    costo_total = 0.0
    for caso in casos:
        try:
            r = responder(caso["pregunta"], contexto, cliente=cliente)
        except AsistenteError as err:
            print(f"[{caso['id']:>2}] ERROR  {caso['pregunta']}  ({err})")
            continue
        ok = es_correcta(r.texto, caso["esperado"])
        correctas += ok
        costo_total += r.costo_usd
        marca = "ok " if ok else "MAL"
        print(f"[{caso['id']:>2}] {marca}  {caso['pregunta']}")
        if not ok:
            print(f"        esperaba {caso['esperado']!r}, respondió: {r.texto!r}")

    n = len(casos)
    print("-" * 60)
    print(f"correctas: {correctas}/{n} ({100 * correctas / n:.0f}%)")
    print(f"costo total: ${costo_total:.4f} USD ({'cliente falso' if args.fake else 'API real'})")
    return 0 if correctas == n else 1


if __name__ == "__main__":
    sys.exit(main())
uv run python -m inventario.evals --fake
Deberías ver
[ 1] ok   ¿A qué temperatura se incuba la lisis?
[ 2] ok   ¿Cuánto tiempo dura la incubación de lisis?
…
[20] ok   ¿Qué primers uso para amplificar el gen 16S?
------------------------------------------------------------
correctas: 20/20 (100%)
costo total: $0.0570 USD (cliente falso)
Ese 100% no dice nada del modelo (el falso responde copiando del caso): dice que tus 20 casos cargan, que la comparación por palabras clave funciona y que el costo se acumula. El "costo" del falso usa tokens fijos inventados: es la tubería contando, no la API cobrando. La medición real viene en el paso 7.

Paso 7 Contra la API real: medir, cambiar el prompt, volver a medir

Con la key en tu .env (cárgala en la sesión con set -a; source .env; set +a, o usa uv run --env-file .env):

uv run --env-file .env python -m inventario.evals
Deberías ver algo así (salida ilustrativa: los números exactos varían por corrida — esa es justamente la lección)
[ 1] ok   ¿A qué temperatura se incuba la lisis?
…
[10] MAL  ¿Cuánto dura la centrifugación con AW2?
        esperaba ['3 min'], respondió: 'La centrifugación dura tres minutos a 20.000 × g.'
…
correctas: 18/20 (90%)
costo total: $0.0980 USD (API real)

Mira el fallo del ejemplo: el modelo respondió bien ("tres minutos") pero tu comparador esperaba "3 min". La mitad del trabajo de evaluar es afinar el criterio de correcto, no solo el prompt. Agrega "tres min" a los esperados de ese caso: corregir el eval también es trabajo legítimo, siempre que lo hagas por escrito y no para maquillar el número.

Ahora el ciclo completo de mejora, con el que cierras el proyecto:

  1. Corre los evals y anota: % correcto, costo total, qué casos fallan y por qué.
  2. Formula una hipótesis: "falla las preguntas de tiempos porque el prompt no le pide unidades".
  3. Cambia una cosa del prompt (una regla, una frase).
  4. Vuelve a correr. ¿Subió el %? ¿Bajó? ¿Cambió el costo?
  5. Anota el antes/después en DECISIONES.md y decide si el cambio se queda.
DECISIONES.md (agregar)
## 2026-08-xx · Prompt del asistente v2
Hipótesis: fallaba tiempos por responder en palabras ("tres minutos").
Cambio: regla "escribe cantidades con número y unidad (3 min, 56 °C)".
Antes:  18/20 (90%), $0.0980 por corrida.
Después: 20/20 (100%), $0.0995 por corrida.
Se queda. Nota: +$0.0015 por respuestas un poco más largas.
Cuánto cuesta esto en serio. Con este protocolo, cada pregunta mueve ~1.100 tokens de entrada y ~80 de salida ≈ $0,005. Una corrida de evals ≈ $0,10. Ahora extrapola como te pide el criterio de listo: 100 usuarios haciendo 10 preguntas al día son 1.000 preguntas/día ≈ $5/día ≈ $150/mes. De pronto el prompt de 200 tokens contra el de 600 importa, y entiendes por qué la gente optimiza prompts: no por elegancia, por plata.

Paso 8 Docker, CI y el PR

El contenedor corre tests y evals falsos: todo lo que no necesita key. CI queda igual que siempre (los tests no llaman al modelo, así que CI no necesita la key ni gasta):

Dockerfile (CMD del proyecto 8)
# Por defecto: tests + evaluación con cliente falso (sin API key, sin costo)
CMD ["sh", "-c", "uv run pytest -q && uv run python -m inventario.evals --fake"]
docker build -t inventario-p8 .
docker run --rm inventario-p8
Deberías ver
13 passed in 0.11s
[ 1] ok   ¿A qué temperatura se incuba la lisis?
…
correctas: 20/20 (100%)
costo total: $0.0570 USD (cliente falso)
git add . && git commit -m "Evals de 20 casos y prompt v2 decidido con números"
git push -u origin feat/asistente-llm
gh pr create --title "Asistente de laboratorio con LLM" --reviewer rotorrest

En el PR, pega la tabla antes/después de tus evals. Es la evidencia de que el proyecto está hecho: no "el asistente responde bien", sino "pasó de 18/20 a 20/20 y sé cuánto cuesta".

Entiende el código

Estructura nueva en el repo

inventario-lab/
├── data/protocolo-extraccion-adn.md   el contexto sobre el que responde
├── evals/casos.json                   20 preguntas con respuesta conocida
├── src/inventario/
│   ├── prompts/asistente.md           el prompt, versionado como código
│   ├── asistente.py                   responder(): la única puerta al modelo
│   ├── evals.py                       el corredor de la evaluación
│   └── api/main.py                    + POST /preguntar
└── tests/
    ├── conftest.py                    el cliente falso
    ├── test_asistente.py              parseo, costos, errores
    └── test_api.py                    el endpoint con dependency_overrides

Las decisiones que importan

  • El cliente se inyecta. responder(…, cliente=None) crea el real solo si nadie pasó uno. Es el mismo patrón que la DB en memoria del P3 y el respx del P5: la frontera con el mundo exterior siempre tiene una puerta por donde los tests meten un doble. Sin ese parámetro, cada test costaría dinero y fallaría distinto cada vez.
  • Salida estructurada, no "por favor devuelve JSON". output_config.format con un esquema le impone el formato al modelo desde la API. Aun así validas con Pydantic: la validación en el borde no se delega, y si el modelo llega cortado por max_tokens el JSON puede venir incompleto igual.
  • El costo es parte del contrato. Respuesta lleva tokens y USD porque lo que no se mide no se controla. La API te dice tokens exactos en usage; el precio lo pones tú (y lo actualizas cuando cambie: por eso es una constante con nombre y comentario, no un número suelto).
  • Cada fallo del modelo tiene nombre. FormatoInvalidoRespuestaIncompletaValueError del usuario. En el endpoint se convierten en 502 vs 422: quien llama sabe si el problema es suyo, tuyo o del modelo.
  • No determinismo ≠ no testeable. Se parte en dos: lo determinista (tu código alrededor del modelo) se testea con el falso y aserciones exactas; lo probabilístico (el modelo) se evalúa con 20 casos y un porcentaje. Confundir los dos planos es el error clásico de los proyectos con LLM.
  • ASISTENTE_MODEL por entorno. Cambiar de modelo (más barato, más nuevo) es config, no código — la regla del P6. Ojo: al cambiar de modelo cambian también los precios y hay que recalibrar los evals.

Vocabulario mínimo de LLM que este proyecto te deja

TérminoQué es aquí
tokenLa unidad de cobro y de límite. ~¾ de palabra en español. usage trae los exactos.
prompt de sistemaLas reglas del asistente (system=). Tu archivo versionado.
contextoLo que el modelo puede "ver" en esta llamada: aquí, el protocolo. El modelo no sabe nada tuyo que no le pases — la idea que explota el P9.
max_tokensTope de salida. Tu freno de gasto y la causa de respuestas cortadas.
stop_reasonPor qué terminó: end_turn (bien) vs max_tokens (cortada). Siempre se revisa.
evalCasos con respuesta conocida + criterio de correcto + un número. Tu control de calidad.
alucinaciónRespuesta inventada con seguridad. Se combate con reglas de prompt, con fuente citada y con el caso 20.

Con Claude

Hay algo circular y útil en este proyecto: usas Claude (el producto) para aprender a usar Claude (la API). Aprovecha esa doble vista — lo que te funciona al pedirle cosas en Claude Code son las mismas palancas que ahora programas: contexto, reglas claras, formato de salida.

Pedidos que sí valen la pena en este proyecto

Explícame la diferencia entre el prompt de sistema y el mensaje de usuario. ¿Por qué el protocolo va en el mensaje y las reglas en el sistema? Aquí están mis 20 casos de eval [pegar]. ¿Qué tipos de pregunta me faltan para cubrir mejor el protocolo? No los escribas: dime las categorías. Mi eval marca MAL este caso pero la respuesta del modelo es correcta [pegar ambos]. ¿El problema es mi prompt o mi criterio de correcto? Argumenta las dos posturas. ¿Qué pasa exactamente si el modelo devuelve el JSON dentro de un bloque de markdown con ```? ¿Mi parser lo aguanta? Diseña el test que lo demuestre antes de darme la solución. Hazme cinco preguntas de criterio sobre costos de LLM en producción y corrige mis respuestas con dureza.

Regla especial de este proyecto: los cambios de prompt los decides tú con los números de tus evals, no porque la IA diga que "este prompt es mejor". Pídele hipótesis, nunca veredictos.

Si algo falla

AuthenticationError: invalid x-api-key (o Could not resolve authentication method)

La key no está llegando al proceso. El .env no se carga solo: usa uv run --env-file .env … o expórtala en la sesión. Verifica con echo ${ANTHROPIC_API_KEY:0:12} (muestra solo el inicio; nunca imprimas la key completa, queda en el historial de la terminal).

Los evals reales dan un % distinto cada corrida

Normal: el modelo es probabilístico. Si oscila 1 caso (18-19/20), es ruido; decide con 2–3 corridas. Si oscila 5, tus criterios de "correcto" son demasiado frágiles (palabras clave muy específicas) o el prompt es ambiguo. Ensancha los esperado antes de tocar el prompt.

FormatoInvalido con el JSON envuelto en ```json … ```

Sin salida estructurada, los modelos tienden a envolver el JSON en un bloque markdown. Con output_config.format no debería pasar; si te pasa, revisa que estás mandando de verdad ese parámetro (el test test_responder_manda_el_protocolo_y_el_prompt lo cubre) y que tu versión del SDK es reciente (uv add anthropic --upgrade).

RespuestaIncompleta aparece seguido

Tu MAX_TOKENS = 500 se queda corto para lo que pides. O subes el tope (sube el costo máximo por respuesta) o le pides al modelo respuestas más cortas en el prompt. Es un dial de costo/calidad, no un bug: decídelo y anótalo.

La corrida de evals tarda minutos

20 llamadas en serie a un LLM tardan 20 × (1–3 s). Es esperable. No lo "arregles" con hilos todavía: primero que funcione y se mida; paralelizar llamadas con costo es exactamente el tipo de optimización que se hace después de medir (regla del cuaderno). Y jamás pongas un reintento automático sin tope: es un bucle que gasta dinero.

pytest falla con ModuleNotFoundError: No module named 'tests'

Falta tests/__init__.py. Sin él, from tests.conftest import … no resuelve. Créalo vacío. (Las fixtures de conftest funcionan sin eso; el import directo de la función auxiliar, no.)

El modelo responde bien en /docs pero el eval lo marca MAL

Estás viendo el fallo más instructivo del proyecto: tu criterio de correcto es más estricto que tu noción de correcto. "3 minutos" vs "3 min" vs "tres minutos". Ensancha las palabras clave del caso o normaliza en es_correcta() — y date cuenta de que acabas de aprender por qué evaluar LLMs es un problema en sí mismo.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"Tu eval da 20/20. ¿Eso demuestra que el asistente no alucina?" (No: demuestra que acierta en esos 20 casos. La alucinación vive fuera del set — por eso el caso 20, la fuente citada y la confianza existen, y por eso el P9 va a obligar al modelo a citar de dónde saca cada cosa.)

Siguiente

Tu asistente responde sobre un protocolo que le cabe entero en el prompt. En el Proyecto 9 · RAG de protocolos y papers le das una biblioteca de decenas de PDFs: como no caben en el contexto, primero hay que buscar los fragmentos relevantes y luego generar con ellos — y aprenderás a medir la búsqueda antes que la respuesta.