Esta lección salda una deuda anunciada dos veces: en 02-04, cuando construimos el conjunto de 30-50 preguntas reales y la rúbrica binaria prometiendo automatizar su ejecución, y en 04-05, cuando la rúbrica creció a 9 criterios y dijimos que "06-03 automatiza esto". DocuBot ya está protegido (06-01) y es respetuoso con los datos (06-02), pero sigue evaluándose a mano: alguien lanza las preguntas, lee las respuestas y rellena un CSV. Eso no escala, no se ejecuta antes de cada despliegue y depende del humor del evaluador. Aquí montamos la pirámide completa de testing para aplicaciones con LLMs: pytest clásico para tu código determinista, tests de contrato sobre las salidas del modelo, un runner que ejecuta el conjunto entero y registra resultados versionados, y un LLM haciendo de juez para los criterios que ningún assert puede verificar — calibrado contra tu evaluación manual, que se convierte en patrón oro.

Contenido

  1. El problema: por qué el testing clásico no basta (y por qué sigue siendo necesario)
  2. Los tres niveles de testing en una aplicación con LLM
  3. Nivel 1: unit tests deterministas con pytest
  4. Nivel 2: tests de contrato y propiedades sobre salidas del LLM
  5. Nivel 3: el runner de evaluación de calidad
  6. LLM-as-judge: automatizar los criterios no deterministas
  7. Regresiones: evaluar antes de desplegar
  8. El coste de evaluar

El problema: por qué el testing clásico no basta (y por qué sigue siendo necesario)

El testing tradicional descansa en una premisa: misma entrada → misma salida. Los LLMs la rompen (el no determinismo fue la limitación nº 4 del módulo 1): la misma pregunta puede producir respuestas distintas, todas aceptables — o no. Un assert respuesta == esperado sobre texto libre fallará el martes y pasará el jueves.

La reacción equivocada es rendirse ("no se puede testear, probamos a mano y ya"). La correcta es darse cuenta de que una aplicación con LLM tiene dos territorios:

  • Tu código: limpiar_json, validar_contrato, el chunking, ejecutar_herramienta, redactar... Determinista al 100%. Se testea con pytest de toda la vida, sin gastar un token.
  • Las salidas del modelo: no deterministas en su literalidad, pero con propiedades que sí deben cumplirse siempre (el JSON es válido, las fuentes existen, la frase anti-alucinación aparece cuando toca) y con cualidades que requieren juicio (¿es fiel a la documentación? ¿es relevante?).

Cada territorio tiene su herramienta. Confundirlos produce los dos errores clásicos: gastar tokens testeando un parser (carísimo, lento, innecesario) o intentar verificar fidelidad con un assert de igualdad (imposible).

Los tres niveles de testing en una aplicación con LLM

Nivel Qué verifica Determinista Herramienta Cuándo se ejecuta Coste
1. Unit tests del código propio Parsers, validadores, chunking, redacción, herramientas con mocks pytest normal En cada commit (segundos) Cero tokens
2. Tests de contrato/propiedades Propiedades invariantes de las salidas del LLM: JSON válido, contrato cumplido, frase anti-alucinación cuando corresponde, nunca inventa fuentes La verificación sí; la salida no pytest + llamadas reales al modelo Antes de desplegar, con pocos casos Bajo
3. Evaluación de calidad Fidelidad, relevancia, corrección sobre el conjunto completo de casos No Runner + criterios automáticos + LLM-as-judge Antes de cada cambio de prompt/modelo/chunking; periódicamente El que hay que gestionar

La forma de pirámide no es casual: muchos tests baratos abajo, pocos y caros arriba. La mayoría de los bugs de DocuBot a lo largo del curso (JSON con texto alrededor, chunks mal cortados, argumentos inválidos) viven en el nivel 1, donde encontrarlos cuesta milisegundos.

Nivel 1: unit tests deterministas con pytest

Nada nuevo bajo el sol — y esa es la noticia buena: la mitad de DocuBot se testea como cualquier proyecto Python. Ejemplos representativos:

# test_validacion.py
import pytest
from validacion import limpiar_json, validar_contrato

def test_limpiar_json_extrae_de_texto_con_ruido():
    crudo = 'Claro, aqui tienes:\n{"categoria": "api", "respuesta": "...", ' \
            '"fuentes": [], "confianza": 0.4}\nEspero que ayude.'
    assert limpiar_json(crudo)["categoria"] == "api"

def test_validar_contrato_rechaza_categoria_desconocida():
    salida = {"categoria": "marciana", "respuesta": "x",
              "fuentes": [], "confianza": 0.9}
    assert not validar_contrato(salida)

def test_validar_contrato_rechaza_confianza_fuera_de_rango():
    salida = {"categoria": "api", "respuesta": "x",
              "fuentes": [], "confianza": 1.7}
    assert not validar_contrato(salida)
# test_chunking.py — el chunking es funcion pura: entrada texto, salida chunks
from chunking import trocear

def test_ningun_chunk_supera_el_maximo():
    texto = "## Titulo\n" + ("parrafo de relleno. " * 500)
    assert all(len(c["texto"]) <= 1800 for c in trocear(texto))

def test_el_solape_preserva_contexto():
    # dos chunks consecutivos comparten los ~300 caracteres de solape
    chunks = trocear("## A\n" + "x" * 4000)
    assert chunks[0]["texto"][-100:] in chunks[1]["texto"]

Y el patrón clave para las herramientas del módulo 5 — mocks: se testea tu fontanería sin llamar ni al LLM ni a la API real.

# test_herramientas.py
from herramientas import ejecutar_herramienta

def test_proyecto_inexistente_devuelve_is_error(monkeypatch):
    # api_nubelia ya es un mock, pero fijamos su estado para el test
    ejecucion = ejecutar_herramienta("consultar_proyecto",
                                     {"proyecto": "inexistente"})
    assert ejecucion["is_error"] is True   # el patron de 05-01

def test_validacion_de_argumentos_corta_antes_de_ejecutar():
    # la allowlist de 06-01, testeada en milisegundos
    ejecucion = ejecutar_herramienta("crear_ticket_soporte",
                                     {"prioridad": "apocaliptica",
                                      "titulo": "t", "descripcion": "d"})
    assert ejecucion["is_error"] is True

Observa el dividendo de las decisiones de arquitectura del curso: como el bucle agéntico es tuyo (05-03) y la lógica vive en módulos separados, cada pieza se testea aislada. La defensa de 06-01 (validar_argumentos) y la redacción de 06-02 (redactar) son funciones puras: entran en este nivel con tests triviales de escribir.

Nivel 2: tests de contrato y propiedades sobre salidas del LLM

Aquí sí llamamos al modelo, pero no preguntamos "¿es esta la respuesta exacta?" sino "¿cumple las propiedades que toda respuesta debe cumplir?". Es la idea del property-based testing adaptada: no conoces la salida, pero conoces sus invariantes.

Las tres propiedades canónicas de DocuBot:

# test_contrato_llm.py — se ejecutan con llamadas reales: pocas y elegidas
import json
import pytest
from rag import responder_con_rag, FRASE_SIN_INFO
from validacion import limpiar_json, validar_contrato
from vectordb import obtener_fuentes_existentes  # ids reales de docs_nubelia

# Propiedad 1: la salida siempre respeta el contrato JSON
@pytest.mark.llm  # marca para poder excluirlos del ciclo rapido: pytest -m "not llm"
def test_salida_cumple_contrato():
    salida = limpiar_json(responder_con_rag("¿Como cambio mi plan de facturacion?"))
    assert validar_contrato(salida)

# Propiedad 2: sin documentacion relevante, aparece la frase anti-alucinacion
@pytest.mark.llm
def test_pregunta_sin_respuesta_activa_frase_sin_info():
    salida = limpiar_json(responder_con_rag(
        "¿Cual es la politica de teletrabajo en Marte?"))  # no existe en la wiki
    assert FRASE_SIN_INFO in salida["respuesta"]
    assert salida["confianza"] <= 0.3

# Propiedad 3: nunca inventa fuentes — toda fuente citada existe en la coleccion
@pytest.mark.llm
def test_fuentes_citadas_existen():
    salida = limpiar_json(responder_con_rag("¿Como creo un webhook?"))
    existentes = obtener_fuentes_existentes()
    assert all(f in existentes for f in salida["fuentes"])

Dos matices de ingeniería:

  • El no determinismo no desaparece: una propiedad puede cumplirse en 2 de 3 ejecuciones. Por eso el hábito de 04-05 (3 ejecuciones por caso) también aplica aquí para los tests inestables: o se ejecuta N veces y se exige N/N (propiedades duras como el contrato), o se acepta un umbral y entonces el caso pertenece al nivel 3, no a este.
  • Pocos casos, muy elegidos: este nivel no recorre el conjunto entero (eso es el nivel 3); usa un puñado de casos centinela — uno por propiedad, quizá uno por categoría del clasificador — para que pueda ejecutarse a menudo sin dolor de presupuesto.

Nivel 3: el runner de evaluación de calidad

Llegamos a la automatización prometida. El runner es un script que: (1) carga el conjunto completo — los 30-50 casos de 02-04 con sus 4 tipos (fáciles ~40%, ambiguos ~25%, sin respuesta ~20%, adversariales suaves ~15%), los chunks anotados de 04-05 y los adversariales de seguridad de 06-01 —; (2) ejecuta cada caso contra el sistema; (3) valida lo automatizable; (4) delega en el juez lo que no; (5) registra un CSV con todas las versiones.

# runner_eval.py
import csv, json, time, uuid
from datetime import date

from conjunto_rag import CONJUNTO_RAG            # casos con chunks anotados (04-05)
from casos_adversariales import CASOS_ADVERSARIALES_SEGURIDAD  # 06-01
from rag import responder_con_rag, FRASE_SIN_INFO
from agente import bucle_agente
from herramientas import HERRAMIENTAS
from validacion import limpiar_json, validar_contrato
from prompts import PROMPT_VERSION
from chunking import CHUNKING_VERSION
from cliente import MODELO
from precios import estimar_coste

EJECUCIONES_POR_CASO = 3   # el habito de 04-05: el no determinismo se muestrea

def ejecutar_caso(caso: dict) -> dict:
    """Ejecuta un caso por la ruta que le corresponde y evalua criterios automaticos."""
    inicio = time.time()
    if caso.get("ruta") == "agente":            # casos multi-paso o de herramientas
        cruda = bucle_agente(caso["pregunta"], HERRAMIENTAS, max_iteraciones=8)
    else:                                        # ruta por defecto: pipeline RAG
        cruda = responder_con_rag(caso["pregunta"])
    duracion = time.time() - inicio

    salida = limpiar_json(cruda)
    resultados = {
        "contrato_valido": validar_contrato(salida) if salida else False,
        "duracion_s": round(duracion, 2),
    }
    if salida:
        # Criterios automaticos: deterministas, sin juez
        if caso["tipo"] == "sin_respuesta":
            resultados["frase_sin_info_ok"] = FRASE_SIN_INFO in salida["respuesta"]
        if "fuentes_esperadas" in caso:          # gracias a los chunks anotados
            citadas = set(salida["fuentes"])
            resultados["fuentes_ok"] = citadas <= set(caso["fuentes_esperadas"])
        resultados["salida"] = salida
    return resultados

def ejecutar_conjunto():
    id_ejecucion = f"{date.today()}-{uuid.uuid4().hex[:8]}"
    casos = CONJUNTO_RAG + CASOS_ADVERSARIALES_SEGURIDAD
    filas = []
    for caso in casos:
        for n in range(EJECUCIONES_POR_CASO):
            r = ejecutar_caso(caso)
            filas.append({
                "id_ejecucion": id_ejecucion,
                "caso": caso["id"], "tipo": caso["tipo"], "repeticion": n + 1,
                "prompt_version": PROMPT_VERSION,
                "chunking_version": CHUNKING_VERSION,   # "encabezados-v1-1800-300"
                "modelo": MODELO,
                **{k: v for k, v in r.items() if k != "salida"},
            })
    with open(f"eval_{id_ejecucion}.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=filas[0].keys())
        w.writeheader(); w.writerows(filas)
    return filas

Puntos que conviene interiorizar:

  • Las versiones van en cada fila. PROMPT_VERSION, CHUNKING_VERSION y MODELO convierten el CSV en un experimento reproducible: dentro de un mes sabrás exactamente qué combinación produjo qué números. Es el mismo principio del CSV de 02-04, ahora automatizado.
  • Cada caso viaja por su ruta real (responder_con_rag o bucle_agente), porque evalúas el sistema que despliegas, no una versión de laboratorio.
  • Los criterios automáticos primero. Contrato válido, frase anti-alucinación en los casos sin_respuesta, fuentes dentro de las esperadas (posible gracias a la anotación de chunks de 04-05): todo eso se resuelve con asserts baratos. El juez solo entra donde el assert no llega.
  • Las métricas de recuperación (recall@k, precision@k, MRR) no se recalculan aquí: viven en eval_rag.py (04-05) y evalúan el retriever aislado. Este runner evalúa el sistema de punta a punta; son diagnósticos complementarios (si el runner empeora, eval_rag.py te dice si el culpable es la recuperación o la generación).

LLM-as-judge: automatizar los criterios no deterministas

Quedan los criterios de la rúbrica de 9 puntos de 04-05 que ningún assert puede comprobar: ¿la respuesta es fiel a los chunks (no añade nada que no esté)? ¿Es relevante a la pregunta? La solución con mejor relación coste/utilidad es usar otro LLM como evaluador: LLM-as-judge.

La idea: el juez recibe la pregunta, los chunks recuperados y la respuesta, y aplica la rúbrica emitiendo veredictos binarios (sí/no) — la misma escala que usaste a mano, porque las escalas de 1 a 10 producen jueces inconsistentes y números pseudo-precisos.

# juez.py
import json
from cliente import client, MODELO_JUEZ, extraer_texto   # puede ser un modelo pequeño
from reintentos import llamar_con_reintentos

PROMPT_JUEZ = """Eres un evaluador estricto de un asistente de documentacion.
Evalua la RESPUESTA unicamente con la DOCUMENTACION proporcionada como referencia.

Criterios (responde exactamente true o false para cada uno):
- fidelidad: toda afirmacion de la respuesta esta respaldada por la documentacion.
  Si la respuesta añade datos, pasos o cifras que no aparecen, es false.
- relevancia: la respuesta aborda directamente la pregunta del usuario.

Devuelve SOLO un JSON: {"fidelidad": bool, "relevancia": bool,
"justificacion": "una frase por criterio"}

PREGUNTA:
{pregunta}

DOCUMENTACION (unica fuente de verdad):
{documentacion}

RESPUESTA A EVALUAR:
{respuesta}"""

def juzgar(pregunta: str, documentacion: str, respuesta: str) -> dict:
    prompt = PROMPT_JUEZ.format(pregunta=pregunta,
                                documentacion=documentacion,
                                respuesta=respuesta)
    salida = llamar_con_reintentos(client, MODELO_JUEZ,
                                   [{"role": "user", "content": prompt}])
    return json.loads(extraer_texto(salida))

El detalle de que el prompt pida justificación no es cosmético: obliga al juez a "razonar" antes del veredicto (el CoT de 02-02 aplicado a la evaluación) y te da material para auditar sus fallos.

Calibración: no te fíes del juez sin medirlo

Un juez es un LLM más: también se equivoca. Antes de delegarle nada, se calibra contra el patrón oro — tus evaluaciones manuales de 02-04 y 04-05, que dejan de ser una carga histórica para convertirse en el activo que valida al juez:

  1. Toma 30-50 casos que ya evaluaste a mano con la rúbrica.
  2. Pásalos por el juez.
  3. Mide el % de acuerdo por criterio (y, si quieres rigor, un coeficiente tipo kappa que descuente el acuerdo por azar).
  4. Regla práctica: con acuerdo ≥ 85-90% en un criterio, el juez puede automatizarlo (revisando por muestreo los desacuerdos); por debajo, itera el prompt del juez o mantén ese criterio en manual.

Sesgos conocidos del juez y mitigaciones

Sesgo Qué hace Mitigación
Posición En comparaciones A/B prefiere sistemáticamente la primera (o última) opción presentada Evaluar cada respuesta aislada contra la rúbrica (como hace nuestro juez); si comparas pares, evalúa en ambos órdenes y descarta si no coincide
Longitud Favorece respuestas más largas y detalladas aunque no sean mejores Criterios binarios anclados a la documentación ("respaldada por", no "completa"); incluir en la calibración casos donde la respuesta corta es la correcta
Auto-preferencia Puntúa mejor las salidas generadas por su propia familia de modelos Usar como juez un modelo distinto del evaluado cuando sea posible; la calibración contra el patrón oro humano detecta el sesgo aunque no puedas evitarlo
Indulgencia Tiende al "sí" en caso de duda Instrucción explícita de estrictitud ("si añade datos que no aparecen, es false") y ejemplos few-shot de veredictos negativos en el prompt del juez

Regresiones: evaluar antes de desplegar

Todo lo anterior cristaliza en una disciplina: ningún cambio de prompt, modelo o chunking se despliega sin pasar la evaluación. Es el equivalente LLM de "no se mergea con tests en rojo", y el motivo por el que versionamos todo desde 02-04.

El flujo, a nivel conceptual (un gate en CI):

flowchart LR
    A[Cambio: PROMPT_VERSION,<br>CHUNKING_VERSION o MODELO] --> B[Nivel 1: pytest<br>segundos, cero tokens]
    B -->|verde| C[Nivel 2: tests de contrato<br>casos centinela]
    C -->|verde| D[Nivel 3: runner + juez<br>conjunto completo]
    D --> E{¿Cumple umbrales y<br>no empeora la base?}
    E -->|si| F[Desplegar y archivar CSV<br>como nueva linea base]
    E -->|no| G[Revisar desacuerdos<br>e iterar el cambio]

Los umbrales de aceptación se definen por adelantado (no después de ver los números, que es como se legalizan las regresiones). Un ejemplo razonable para DocuBot:

  • Contrato JSON válido: 100% (es código + propiedad dura; no hay excusa).
  • Frase anti-alucinación en casos sin_respuesta: ≥ 95%.
  • Casos adversariales de 06-01 superados: 100% de los deterministas (la allowlist no negocia), ≥ 90% de los juzgados.
  • Fidelidad (juez): ≥ 90% y nunca más de 2 puntos por debajo de la línea base anterior.

Ese segundo tipo de umbral — comparar contra la ejecución anterior, no solo contra un mínimo absoluto — es lo que detecta regresiones: el CSV archivado de la última versión buena es tu línea base, y las columnas de versión te dicen exactamente qué comparar con qué.

El coste de evaluar

Una ejecución completa tiene un precio que ya sabes estimar con estimar_coste() (03-04): ~45 casos × 3 ejecuciones × (1 llamada al sistema + 1 al juez) ≈ 270 llamadas. Palancas para que evaluar no duela:

  • Modelo pequeño como juez. Verificar contra una rúbrica es más fácil que generar; los modelos pequeños rinden bien como jueces de criterios binarios y cuestan una fracción. La calibración contra el patrón oro te dice si el pequeño basta — es una decisión medida, no una corazonada.
  • Escalonar: los niveles 1 y 2 corren siempre (gratis o casi); el nivel 3 completo, en cambios de prompt/modelo/chunking y de forma periódica.
  • Muestreo: para el ciclo rápido de iteración, un subconjunto estratificado (manteniendo las proporciones 40/25/20/15 de los tipos de caso) da señal direccional barata; el conjunto completo se reserva para la decisión de despliegue.
  • Juez solo donde hace falta: los criterios automáticos filtran primero; si el contrato ya es inválido, no se gasta juez en evaluar fidelidad.

Orden de magnitud con los precios de precios.py: una ejecución completa con juez pequeño queda típicamente en céntimos-pocos euros — comparado con los ~417 €/mes de operación proyectados en 03-04, el seguro antirregresiones es de las partidas más baratas del sistema.

Errores Comunes y Consejos

  • Testear el LLM con asserts de igualdad exacta. assert respuesta == "..." sobre texto libre es un test intermitente garantizado. Sobre las salidas del modelo se verifican propiedades (contrato, frase, fuentes), no literales.
  • Gastar tokens en testear tu propio código. limpiar_json no necesita un LLM para ser testeado; necesita 10 casos retorcidos en pytest. Cada cosa en su nivel de la pirámide.
  • Fiarse del juez sin calibrarlo. Un juez sin % de acuerdo medido contra el patrón oro es otra fuente de números bonitos e inservibles. Primero acuerdo ≥ 85-90%, luego confianza — y aun así, muestreo periódico de sus veredictos.
  • Evaluar con 1 ejecución por caso. El no determinismo convierte una ejecución única en una moneda al aire. Tres ejecuciones (el hábito de 04-05) distinguen "falla siempre" de "falla a veces", que son bugs distintos.
  • Olvidar las versiones en el registro. Un CSV sin PROMPT_VERSION/CHUNKING_VERSION/MODELO es un número huérfano: no puedes comparar, no puedes reproducir, no puedes hacer bisección de regresiones.
  • Definir los umbrales después de ver los resultados. Es la vía rápida para normalizar regresiones. Umbral primero, ejecución después — y la línea base anterior como referencia obligatoria.
  • Confundir evaluación offline con monitorización. Todo lo de esta lección ocurre antes de desplegar, con casos conocidos. Lo que pasa en producción con preguntas reales de usuarios reales es el territorio de 06-04.

Ejercicios

Ejercicio 1: clasificar verificaciones en la pirámide

Para cada verificación, decide a qué nivel pertenece (1, 2 o 3) y con qué mecanismo se comprueba (assert determinista, propiedad con llamada real, criterio automático del runner o LLM-as-judge): (a) estimar_coste() calcula bien el precio de 1.000 tokens de entrada y 500 de salida; (b) toda respuesta de DocuBot es JSON parseable que pasa validar_contrato; (c) las respuestas a preguntas sobre facturación son fieles a la documentación recuperada; (d) redactar() sustituye dos emails distintos del mismo texto; (e) ante un caso adversarial con instrucción intrusa en el chunk, la respuesta no reproduce la afirmación falsa.

Ejercicio 2: escribir un test de propiedad nuevo

Escribe un test de nivel 2 para esta propiedad de DocuBot: "si la respuesta tiene confianza ≥ 0.7, entonces fuentes no puede estar vacía" (una respuesta segura sin fuentes es sospechosa de alucinación). Usa responder_con_rag, limpiar_json y una pregunta que sí tenga respuesta en la wiki.

Ejercicio 3: diseñar la calibración del juez

Tienes 40 casos evaluados a mano para el criterio fidelidad (patrón oro: 31 "sí", 9 "no"). Pasas el juez y obtienes: coincide en 29 de los "sí" y en 5 de los "no". (a) Calcula el % de acuerdo global. (b) ¿Qué proporción de los "no" humanos detecta el juez, y por qué ese número importa más que el acuerdo global en este criterio? (c) ¿Automatizarías ya este criterio? ¿Qué harías antes?

Soluciones

Ejercicio 1:

  • (a) Nivel 1, assert determinista: estimar_coste es aritmética pura; pytest sin tokens.
  • (b) Nivel 2, propiedad con llamadas reales sobre casos centinela (y además criterio automático en cada fila del runner de nivel 3 — las propiedades duras se comprueban en ambos sitios porque en el nivel 3 salen gratis).
  • (c) Nivel 3, LLM-as-judge: la fidelidad requiere comparar semánticamente respuesta y documentación; ningún assert lo hace.
  • (d) Nivel 1, assert determinista: redactar es una función pura de la 06-02.
  • (e) Nivel 3, criterio del runner: automatizable en parte con búsqueda de la afirmación falsa en la respuesta (determinista) y completable con juez para los matices — tal como se diseñó el criterio_exito en 06-01.

Ejercicio 2:

import pytest
from rag import responder_con_rag
from validacion import limpiar_json

@pytest.mark.llm
def test_confianza_alta_implica_fuentes():
    """Propiedad: confianza >= 0.7 => fuentes no vacia.
    Una respuesta 'segura' sin fuentes es el perfil tipico de una alucinacion."""
    salida = limpiar_json(responder_con_rag("¿Como creo un webhook en Nubelia?"))
    assert salida is not None, "la salida no es JSON parseable"
    if salida["confianza"] >= 0.7:
        assert salida["fuentes"], (
            f"confianza {salida['confianza']} sin fuentes: posible alucinacion"
        )

Observa la forma lógica: la propiedad es una implicación ("si confianza alta, entonces fuentes"), así que el test no falla si el modelo devuelve confianza baja — eso sería otro caso, no una violación. Es un patrón habitual en tests de propiedades: verificar el invariante solo cuando su precondición se da (y tener casos que garanticen que la precondición se da a menudo).

Ejercicio 3:

  • (a) Acuerdos: 29 + 5 = 34 de 40 → 85% de acuerdo global.
  • (b) De los 9 "no" humanos (respuestas infieles), el juez detecta 5 → 55,6%. Importa más que el 85% global porque los "no" son precisamente lo que el juez existe para cazar: una respuesta infiel que pasa por buena es el error caro (alucinación en producción), mientras que el acuerdo global está inflado por la mayoría de casos "sí" fáciles. Es el clásico problema de clases desbalanceadas: mira el recall de la clase minoritaria, no solo la exactitud.
  • (c) Todavía no. El 85% roza el umbral, pero detectar solo la mitad de las infidelidades es insuficiente. Antes: leer los 4 "no" que el juez dejó escapar (las justificaciones del juez ayudan), reforzar el prompt del juez con instrucciones de estrictitud y 1-2 ejemplos few-shot de veredictos negativos parecidos a los fallados, quizá probar otro modelo como juez, y recalibrar. Automatizar solo cuando la detección de "no" sea alta de forma estable — y aun entonces, seguir muestreando desacuerdos.

Conclusión

La deuda de 02-04 y 04-05 está saldada: la evaluación de DocuBot ya no depende de una tarde heroica con un CSV, sino de una pirámide que corre sola — pytest para el código determinista (parsers, chunking, validación de argumentos, redacción: la mitad del sistema se testea gratis), tests de propiedades para los invariantes de las salidas del modelo (contrato JSON, frase anti-alucinación, fuentes que existen), y el runner que ejecuta el conjunto completo — fáciles, ambiguos, sin respuesta, adversariales suaves y los adversariales de seguridad de 06-01 — registrando cada fila con PROMPT_VERSION, CHUNKING_VERSION y MODELO para que cada número tenga apellidos. Donde el assert no llega, un LLM-as-judge con la rúbrica de 04-05 en escala binaria emite los veredictos de fidelidad y relevancia — pero solo después de ganarse la confianza contra tu patrón oro manual, y vigilado por sus sesgos conocidos. Con umbrales definidos por adelantado y la línea base anterior como vara de medir, "¿puedo desplegar este cambio de prompt?" pasa de acto de fe a semáforo. Pero este semáforo solo ve las preguntas que tú imaginaste: el día del despliegue empiezan las preguntas que nadie imaginó, los costes reales, las latencias de la tarde del lunes y los usuarios que se van sin decir por qué. Ver lo que pasa ahí dentro — convertir las trazas que sembramos en 05-02 en observabilidad de verdad, con logs estructurados, métricas, paneles y alertas accionables — es la penúltima lección del curso.

© Copyright 2026. Todos los derechos reservados