Módulo 7: Evaluación de Prompts

3. LLM-as-Judge

Descripción

Usar un LLM para evaluar outputs de otro LLM. Rubrics detallados, escalas de scoring, comparison-based evaluation. Biases del LLM-judge y cómo mitigarlos. Cuándo confiar en el juicio del LLM.


El Problema que Resuelve

Para métricas como faithfulness, relevance o quality, no existe una función determinista que las calcule. No puedes comparar strings y detectar si hay hallucinations. No puedes parsear un JSON y saber si es "relevante".

LLM-as-judge es la solución: usas un LLM (el "juez") para evaluar el output de otro LLM (el "modelo evaluado").

Flujo LLM-as-judge:

Input → [Modelo evaluado] → Output
                               ↓
            Input + Output → [LLM Judge] → Score + Justificación

Cuándo usar LLM-as-judge vs métricas automáticas

SituaciónUsar LLM-judgeUsar métrica automática
Clasificación con respuesta fijaNoSí (accuracy)
Resumen de texto libreROUGE (complemento)
Detección de hallucinationsNo disponible
Respuesta a preguntaBLEU (limitado)
Formato JSONNoSí (JSON parsing)
Tono y estiloNo disponible
Código funcionalParcialEjecución + tests

Principios de un Buen LLM-Judge

Para que el juicio del LLM sea confiable, el prompt del juez debe:

  1. Ser específico: Criterios claros, no "evalúa la calidad"
  2. Ser calibrado: Incluir ejemplos de cada nivel (anchoring)
  3. Ser reproducible: Temperature=0, mismo modelo siempre
  4. Ser consistente: Misma escala, mismos criterios en todas las evaluaciones
  5. Ser explicable: El juez debe justificar su score

Rubric-Based Scoring

El rubric es el documento de criterios que le das al juez. La calidad del rubric determina la calidad del juicio.

Rubric Simple (Score Global)

from openai import OpenAI

client = OpenAI()

RUBRIC_SIMPLE = """
Evalúa la respuesta en una escala del 1 al 5:
1 = Completamente incorrecta o irrelevante
2 = Mayormente incorrecta con algunos elementos útiles
3 = Parcialmente correcta pero incompleta o con errores
4 = Mayormente correcta con detalles menores incorrectos
5 = Completamente correcta, completa y relevante

Responde solo con el número (1-5). Nada más.
"""

def llm_judge_simple(pregunta: str, respuesta: str) -> int:
    """Judge simple: score del 1 al 5."""
    prompt = f"""{RUBRIC_SIMPLE}

Pregunta: {pregunta}
Respuesta: {respuesta}

Score:"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=5
    )
    
    try:
        score_text = response.choices[0].message.content.strip()
        return int(score_text[0])  # Solo el primer dígito
    except (ValueError, IndexError):
        return 3  # Default si no puede parsear

Rubric Multi-Criterio (Detallado)

Para evaluaciones más profundas, descompone en dimensiones:

RUBRIC_DETALLADO = """
Evalúa la respuesta en 3 dimensiones (0-2 puntos cada una):

1. CORRECCIÓN (0-2):
   - 0: Información factualmente incorrecta
   - 1: Mayormente correcto con un error menor
   - 2: Completamente correcto y verificable

2. COMPLETITUD (0-2):
   - 0: No responde lo que se preguntó
   - 1: Responde parcialmente, falta información importante
   - 2: Responde todo lo que se preguntó

3. CLARIDAD (0-2):
   - 0: Confuso, difícil de entender
   - 1: Comprensible pero podría ser más claro
   - 2: Muy claro y bien organizado

TOTAL POSIBLE: 6 puntos

Responde EXACTAMENTE en este formato:
CORRECCIÓN: X/2
COMPLETITUD: X/2
CLARIDAD: X/2
TOTAL: X/6
JUSTIFICACIÓN: [Una oración explicando la evaluación]
"""

def llm_judge_detallado(
    pregunta: str,
    respuesta: str,
    ground_truth: str = ""
) -> dict:
    """Judge multi-criterio con justificación."""
    
    gt_str = f"\nRespuesta de referencia (ground truth): {ground_truth}" if ground_truth else ""
    
    prompt = f"""{RUBRIC_DETALLADO}

Pregunta: {pregunta}{gt_str}
Respuesta a evaluar: {respuesta}

Evaluación:"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=200
    )
    
    raw = response.choices[0].message.content
    return parsear_rubric_detallado(raw)


def parsear_rubric_detallado(raw: str) -> dict:
    """Parsea el output del rubric detallado."""
    import re
    
    resultado = {
        "correccion": 0,
        "completitud": 0,
        "claridad": 0,
        "total": 0,
        "max": 6,
        "score_normalizado": 0.0,
        "justificacion": "",
        "raw": raw
    }
    
    # Extraer scores
    correccion_match = re.search(r'CORRECCIÓN:\s*(\d)/2', raw)
    completitud_match = re.search(r'COMPLETITUD:\s*(\d)/2', raw)
    claridad_match = re.search(r'CLARIDAD:\s*(\d)/2', raw)
    total_match = re.search(r'TOTAL:\s*(\d)/6', raw)
    just_match = re.search(r'JUSTIFICACIÓN:\s*(.+)', raw)
    
    if correccion_match:
        resultado["correccion"] = int(correccion_match.group(1))
    if completitud_match:
        resultado["completitud"] = int(completitud_match.group(1))
    if claridad_match:
        resultado["claridad"] = int(claridad_match.group(1))
    if total_match:
        resultado["total"] = int(total_match.group(1))
    else:
        resultado["total"] = resultado["correccion"] + resultado["completitud"] + resultado["claridad"]
    
    resultado["score_normalizado"] = resultado["total"] / 6.0
    
    if just_match:
        resultado["justificacion"] = just_match.group(1).strip()
    
    return resultado

Rubric con Anchoring (Ejemplos de Calibración)

El anchoring es la técnica más efectiva para reducir la varianza del juez:

RUBRIC_CON_ANCHORING = """
Evalúa la FIDELIDAD de un resumen respecto al texto original (0-5).

EJEMPLOS DE CALIBRACIÓN:

Score 5 — El resumen solo usa información del texto original:
  Original: "Apple fundó en 1976 en California. Steve Jobs fue cofundador."
  Resumen: "Apple fue fundada en 1976 en California, con Steve Jobs como cofundador."
  → Score: 5 ✓

Score 3 — El resumen hace inferencias razonables pero no verificables:
  Original: "Apple fundó en 1976. En los 80s lanzó el Mac."
  Resumen: "Apple, fundada en 1976, fue pionera en computadoras personales con el Mac."
  → Score: 3 (inferencia de "pionera" no está en el texto)

Score 1 — El resumen inventa información:
  Original: "Apple fundó en 1976 en California."
  Resumen: "Apple fundada en 1976 en California, actualmente la empresa más valiosa del mundo."
  → Score: 1 (no hay info de "más valiosa del mundo")

Ahora evalúa:
Original: {original}
Resumen: {resumen}

Responde SOLO: "Score: X/5" donde X es tu evaluación.
"""

def judge_con_anchoring(original: str, resumen: str) -> float:
    """LLM judge con anchoring para máxima consistencia."""
    prompt = RUBRIC_CON_ANCHORING.format(original=original, resumen=resumen)
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=20
    )
    
    raw = response.choices[0].message.content
    
    import re
    match = re.search(r'Score:\s*(\d)', raw)
    if match:
        return int(match.group(1)) / 5.0
    return 0.5

Comparison-Based Evaluation

En lugar de puntuar en absoluto, compara dos outputs directamente. Es más confiable para detectar cuál es mejor.

def compare_responses(
    pregunta: str,
    resp_a: str,
    resp_b: str,
    criterios: str = "corrección, completitud y claridad"
) -> dict:
    """
    Compara dos respuestas y determina cuál es mejor.
    
    Retorna: "A", "B", o "EMPATE"
    """
    prompt = f"""Compara estas dos respuestas a la misma pregunta.
Evalúa basándote en: {criterios}

Pregunta: {pregunta}

Respuesta A:
{resp_a}

Respuesta B:
{resp_b}

¿Cuál respuesta es mejor?
- Responde "A" si la Respuesta A es claramente mejor
- Responde "B" si la Respuesta B es claramente mejor
- Responde "EMPATE" si son equivalentes en calidad

Responde SOLO con: A, B, o EMPATE"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=10
    )
    
    resultado = response.choices[0].message.content.strip().upper()
    
    # Normalizar respuesta
    if "EMPATE" in resultado:
        winner = "EMPATE"
    elif resultado.startswith("A"):
        winner = "A"
    elif resultado.startswith("B"):
        winner = "B"
    else:
        winner = "EMPATE"  # Default conservador
    
    return {
        "winner": winner,
        "raw": resultado,
        "resp_a": resp_a[:100] + "...",
        "resp_b": resp_b[:100] + "..."
    }


def tournament_evaluation(
    pregunta: str,
    respuestas: dict[str, str]
) -> dict[str, int]:
    """
    Torneo round-robin entre N respuestas.
    Cada par se compara, ganador gana un punto.
    
    respuestas: {"nombre": "texto_respuesta"}
    """
    from itertools import combinations
    
    puntos = {nombre: 0 for nombre in respuestas}
    
    for (nombre_a, resp_a), (nombre_b, resp_b) in combinations(respuestas.items(), 2):
        resultado = compare_responses(pregunta, resp_a, resp_b)
        
        if resultado["winner"] == "A":
            puntos[nombre_a] += 1
        elif resultado["winner"] == "B":
            puntos[nombre_b] += 1
        else:  # EMPATE
            puntos[nombre_a] += 0.5
            puntos[nombre_b] += 0.5
    
    return dict(sorted(puntos.items(), key=lambda x: x[1], reverse=True))


# Ejemplo: Comparar 3 versiones de un prompt
pregunta = "¿Cómo funciona el machine learning?"
respuestas = {
    "zero_shot": "El machine learning es...",
    "few_shot": "El machine learning aprende...",
    "cot": "Para entender machine learning, primero...",
}

ranking = tournament_evaluation(pregunta, respuestas)
print("Ranking:", ranking)
# Ejemplo: {"cot": 2, "few_shot": 1, "zero_shot": 0}

Biases del LLM-Judge y Cómo Mitigarlos

El LLM-judge no es imparcial. Tiene biases conocidos que pueden distorsionar los resultados.

Bias 1: Length Bias (Verbosity Bias)

El juez tiende a preferir respuestas más largas, asumiendo que más texto = más información.

Ejemplo:
Respuesta A: "Python es mejor para ML debido a su ecosistema de librerías."
Respuesta B: "Python es una opción razonable para ML, aunque también hay otras opciones como R y Julia que tienen sus propias fortalezas dependiendo del caso de uso específico que se esté considerando."

Sin bias correction: Judge prefiere B (más larga)
Con bias correction: A puede ser mejor (más concisa y directa)

Mitigación:

RUBRIC_ANTI_LENGTH_BIAS = """
IMPORTANTE: Evalúa el CONTENIDO, no la longitud.
Una respuesta corta y precisa es MEJOR que una respuesta larga con relleno.
Penaliza activamente las respuestas que añaden palabras innecesarias.

Criterio de calidad: ¿Cada oración añade información útil? Si no, reduce el score.
"""

def judge_sin_length_bias(pregunta: str, respuesta: str) -> float:
    """Judge con instrucciones explícitas contra length bias."""
    prompt = f"""{RUBRIC_ANTI_LENGTH_BIAS}

Evalúa (0-10): ¿Cuán bien responde esta respuesta la pregunta?

Pregunta: {pregunta}
Respuesta: {respuesta}

Score (0-10):"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=5
    )
    
    try:
        return float(response.choices[0].message.content.strip()) / 10.0
    except ValueError:
        return 0.5

Bias 2: Position Bias

En comparison-based, el juez tiende a preferir la primera respuesta (si ambas son similares).

Mitigación: Evaluar en ambas direcciones y promediar:

def compare_sin_position_bias(
    pregunta: str,
    resp_a: str,
    resp_b: str
) -> dict:
    """
    Evalúa A vs B y B vs A, luego combina los resultados.
    Elimina el position bias.
    """
    # Comparación directa: A primero
    resultado_ab = compare_responses(pregunta, resp_a, resp_b)
    
    # Comparación inversa: B primero (posiciones intercambiadas)
    resultado_ba = compare_responses(pregunta, resp_b, resp_a)
    
    # Interpretar: en resultado_ba, "A" (primer lugar) es resp_b, "B" es resp_a
    winner_ab = resultado_ab["winner"]
    winner_ba_en_original = {
        "A": "B",  # En BA, "A" es resp_b → resp_b ganó → "B" en original
        "B": "A",  # En BA, "B" es resp_a → resp_a ganó → "A" en original
        "EMPATE": "EMPATE"
    }[resultado_ba["winner"]]
    
    # Consolidar
    if winner_ab == winner_ba_en_original:
        # Ambas evaluaciones coinciden → resultado confiable
        winner_final = winner_ab
        confianza = "ALTA"
    elif winner_ab == "EMPATE" or winner_ba_en_original == "EMPATE":
        # Una es empate → usar la que no es empate
        winner_final = winner_ab if winner_ba_en_original == "EMPATE" else winner_ba_en_original
        confianza = "MEDIA"
    else:
        # Contradicción → empate conservador
        winner_final = "EMPATE"
        confianza = "BAJA (posible position bias detectado)"
    
    return {
        "winner": winner_final,
        "confianza": confianza,
        "resultado_ab": winner_ab,
        "resultado_ba_normalizado": winner_ba_en_original
    }

Bias 3: Self-Enhancement Bias

Si el juez es el mismo modelo que generó las respuestas, tiende a preferir su propio estilo.

Problema: Evalúas con gpt-4o-mini los outputs de gpt-4o-mini
→ El juez favorece respuestas que suenan como gpt-4o-mini

Mitigación:

  • Usar un modelo diferente como juez (ej. Claude como juez de GPT)
  • Si no es posible, usar un modelo más potente como juez (gpt-4o como juez de gpt-4o-mini)
  • Evaluar con múltiples jueces y promediar

Bias 4: Sycophancy

El juez puede ser complaciente: si incluyes la "respuesta correcta" en el prompt, tenderá a dar un score alto independientemente de la calidad real.

Mitigación:

# MAL: Contamina el juicio
prompt_contaminado = f"""
La respuesta correcta es: {ground_truth}
Evalúa si esta respuesta es correcta: {output}
"""

# BIEN: Ground truth como referencia, no como "respuesta correcta"
prompt_limpio = f"""
Contexto de referencia (para verificar hechos): {ground_truth}
Evalúa la calidad de esta respuesta sin considerar si coincide exactamente con la referencia:
{output}
"""

Evaluación con Múltiples Jueces

Para mayor confiabilidad, usa múltiples llamadas o múltiples modelos:

def judge_con_consenso(
    pregunta: str,
    respuesta: str,
    n_jueces: int = 3
) -> dict:
    """
    Evalúa N veces y calcula consenso.
    Reduce varianza por aleatoriedad residual.
    
    Nota: Aunque usamos temperature=0, puede haber variación mínima.
    En modelos más recientes, temperatura 0 no es 100% determinista.
    """
    scores = []
    
    for i in range(n_jueces):
        prompt = f"""Evalúa (1-10) cuán bien responde la respuesta a la pregunta.
Solo el número.

Pregunta: {pregunta}
Respuesta: {respuesta}

Score:"""
        
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0
        )
        
        try:
            score = float(response.choices[0].message.content.strip())
            scores.append(score / 10.0)
        except ValueError:
            pass
    
    if not scores:
        return {"score": 0.5, "variance": 0, "n": 0}
    
    mean = sum(scores) / len(scores)
    variance = sum((s - mean) ** 2 for s in scores) / len(scores)
    
    return {
        "score": mean,
        "variance": variance,
        "scores": scores,
        "n": len(scores),
        "confianza": "ALTA" if variance < 0.01 else "MEDIA" if variance < 0.04 else "BAJA"
    }

Validar el LLM-Judge

El juez también necesita ser evaluado. ¿Qué tan bien correlaciona con evaluación humana?

def validar_correlacion_human_judge(
    evaluaciones_humanas: list[float],
    evaluaciones_llm: list[float]
) -> dict:
    """
    Calcula correlación de Pearson y Spearman entre evaluaciones humanas y del LLM.
    
    Interpretación:
    > 0.8: Excelente — el LLM-judge es confiable
    0.6-0.8: Bueno — usar con precaución
    < 0.6: Pobre — revisar el rubric
    """
    from scipy import stats
    
    pearson_r, pearson_p = stats.pearsonr(evaluaciones_humanas, evaluaciones_llm)
    spearman_r, spearman_p = stats.spearmanr(evaluaciones_humanas, evaluaciones_llm)
    
    return {
        "pearson_r": pearson_r,
        "pearson_p": pearson_p,
        "spearman_r": spearman_r,
        "spearman_p": spearman_p,
        "interpretacion": (
            "EXCELENTE" if pearson_r > 0.8 else
            "BUENO" if pearson_r > 0.6 else
            "POBRE — revisar rubric"
        )
    }


# Proceso de validación:
# 1. Tomar 50-100 ejemplos
# 2. Evaluar con humanos (gold standard)
# 3. Evaluar con LLM-judge
# 4. Calcular correlación
# 5. Si correlación > 0.7: el LLM-judge es confiable para este tipo de tarea

Patrones de Producción

Patrón 1: Judge como servicio

class LLMJudge:
    """Judge reutilizable para múltiples métricas."""
    
    def __init__(self, model: str = "gpt-4o-mini"):
        self.client = OpenAI()
        self.model = model
    
    def score(
        self,
        dimension: str,
        pregunta: str,
        respuesta: str,
        contexto: str = ""
    ) -> dict:
        """
        dimension: "faithfulness" | "relevance" | "quality" | "safety"
        """
        rubrics = {
            "faithfulness": "¿El output contiene solo información del input? 0=inventa, 1=fiel",
            "relevance": "¿El output responde la pregunta? 0=irrelevante, 10=perfectamente relevante",
            "quality": "¿La respuesta es útil, correcta y bien escrita? 1-5",
            "safety": "¿El output es seguro y apropiado? 0=unsafe, 1=safe"
        }
        
        if dimension not in rubrics:
            raise ValueError(f"Dimensión no soportada: {dimension}")
        
        ctx_str = f"\nContexto: {contexto}" if contexto else ""
        
        prompt = f"""{rubrics[dimension]}

Pregunta: {pregunta}{ctx_str}
Respuesta: {respuesta}

Responde solo con el número:"""
        
        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            max_tokens=10
        )
        
        raw = response.choices[0].message.content.strip()
        
        try:
            score = float(raw.split()[0])
        except (ValueError, IndexError):
            score = 0.5
        
        return {
            "dimension": dimension,
            "score": score,
            "raw": raw
        }
    
    def evaluar_completo(self, pregunta: str, respuesta: str, input_texto: str = "") -> dict:
        """Evalúa en todas las dimensiones y retorna resumen."""
        resultados = {}
        
        for dim in ["faithfulness", "relevance", "quality", "safety"]:
            r = self.score(dim, pregunta, respuesta, contexto=input_texto)
            resultados[dim] = r["score"]
        
        # Normalizar a 0-1
        def normalizar(score, dim):
            if dim == "faithfulness": return score  # ya 0-1
            if dim == "relevance": return score / 10.0
            if dim == "quality": return (score - 1) / 4.0
            if dim == "safety": return score  # ya 0-1
            return score
        
        return {dim: normalizar(score, dim) for dim, score in resultados.items()}


# Uso:
judge = LLMJudge()
scores = judge.evaluar_completo(
    pregunta="¿Qué es Python?",
    respuesta="Python es un lenguaje de programación interpretado, de alto nivel.",
    input_texto="Python es un lenguaje creado por Guido van Rossum en 1991."
)
print(scores)

Troubleshooting

Problema 1: Juez demasiado generoso (score siempre alto)

Síntoma: El juez da 4-5/5 a casi todo.

Causa: Rubric sin calibración, o el modelo tiende a ser positivo.

Solución:

# Añadir instrucción explícita de calibración:
CALIBRACION = """
IMPORTANTE: Usa la escala COMPLETA.
- Espero que el 20% de las respuestas obtengan 1-2 (pobres)
- El 30% obtengan 3 (aceptable)
- El 40% obtengan 4 (bueno)
- Solo el 10% más excepcionales obtengan 5

Sé crítico. Una respuesta "completa" pero con errors menores es 3, no 5.
"""

Problema 2: Score inconsistente entre runs

Síntoma: El mismo input recibe scores diferentes en runs consecutivos.

Causa: Temperature > 0, o el prompt es ambiguo.

Solución:

# 1. Forzar temperature=0
# 2. Usar formato de output más estructurado
# 3. Si la varianza persiste, promediar N runs

def score_estable(pregunta, respuesta, n=3):
    scores = []
    for _ in range(n):
        r = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": f"Evalúa (1-5): {respuesta}"}],
            temperature=0
        )
        try:
            scores.append(int(r.choices[0].message.content.strip()[0]))
        except:
            pass
    return sum(scores) / len(scores) if scores else 0

Problema 3: Costo del judge en producción

Síntoma: El judge duplica o triplica el costo de la pipeline de evaluación.

Solución:

# Estrategia 1: Sampling — evalúa solo el 10% del tráfico
import random

def should_judge(rate: float = 0.1) -> bool:
    return random.random() < rate

# Estrategia 2: Solo evaluar casos de bajo confidence
def judge_selectivo(output: str, confidence: float) -> bool:
    return confidence < 0.8  # Solo judge si el modelo no está seguro

# Estrategia 3: Modelo más pequeño para judge cuando es posible
# gpt-4o-mini como judge es 10x más barato que gpt-4o

Problema 4: Judge que no sigue el formato pedido

Síntoma: El judge devuelve texto largo en lugar del número pedido.

Solución:

import re

def parsear_score_robusto(raw: str, max_score: float = 10.0) -> float:
    """Parser robusto para scores que pueden venir en formatos variables."""
    
    # Intentar extraer número con regex
    patterns = [
        r'\b(\d+(?:\.\d+)?)/\d+',  # "7/10" o "3.5/5"
        r'\b(\d+(?:\.\d+)?)\b',     # Número standalone
        r'score[:\s]+(\d+)',         # "score: 7"
        r'(\d+)[/\s]*(puntos|points)',  # "7 puntos"
    ]
    
    for pattern in patterns:
        match = re.search(pattern, raw.lower())
        if match:
            score = float(match.group(1))
            return min(score, max_score)  # Clamp al máximo
    
    # Si falla todo, retornar score medio
    return max_score / 2.0

Ejercicios

Ejercicio 1: Rubric personalizado para tu caso de uso

Crea un rubric para evaluar respuestas de un chatbot de soporte técnico. Debe evaluar: precisión técnica, claridad para no-técnicos, y accionabilidad.

Ver solución
RUBRIC_SOPORTE_TECNICO = """
Evalúa esta respuesta de soporte técnico en 3 dimensiones:

1. PRECISIÓN TÉCNICA (0-3):
   - 0: Información incorrecta que podría empeorar el problema
   - 1: Información vaga o incompleta
   - 2: Mayormente correcto con algún detalle faltante
   - 3: Técnicamente preciso y completo

2. CLARIDAD (0-3):
   - 0: Un no-técnico no podría seguir las instrucciones
   - 1: Algunas partes son claras pero hay jerga no explicada
   - 2: Mayormente claro, un no-técnico lo entendería con esfuerzo
   - 3: Un no-técnico puede seguir los pasos sin ayuda adicional

3. ACCIONABILIDAD (0-3):
   - 0: No hay pasos concretos que el usuario pueda seguir
   - 1: Hay un próximo paso pero no es suficientemente específico
   - 2: Pasos razonablemente específicos
   - 3: Pasos exactos, en orden, con lo que hacer si falla cada uno

TOTAL: 0-9 puntos

Responde:
PRECISIÓN: X/3
CLARIDAD: X/3
ACCIONABILIDAD: X/3
TOTAL: X/9
SUGERENCIA: [Una mejora concreta para la respuesta]
"""

def evaluar_soporte_tecnico(pregunta_usuario: str, respuesta_soporte: str) -> dict:
    from openai import OpenAI
    import re
    
    client = OpenAI()
    
    prompt = f"""{RUBRIC_SOPORTE_TECNICO}

Pregunta del usuario: {pregunta_usuario}
Respuesta de soporte: {respuesta_soporte}

Evaluación:"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    
    raw = response.choices[0].message.content
    
    total_match = re.search(r'TOTAL:\s*(\d+)/9', raw)
    sugerencia_match = re.search(r'SUGERENCIA:\s*(.+)', raw)
    
    return {
        "total": int(total_match.group(1)) if total_match else 0,
        "max": 9,
        "score_normalizado": int(total_match.group(1)) / 9 if total_match else 0,
        "sugerencia": sugerencia_match.group(1) if sugerencia_match else "",
        "raw": raw
    }

Ejercicio 2: Position bias test

Implementa un test que demuestre el position bias: toma dos respuestas donde A es claramente peor, evalúa como A vs B y como B vs A, y verifica si hay inconsistencia.

Ver solución
from openai import OpenAI
client = OpenAI()

def test_position_bias():
    pregunta = "¿Cuál es la capital de Francia?"
    
    # Respuesta claramente mejor
    resp_buena = "La capital de Francia es París. Es también la ciudad más grande del país."
    
    # Respuesta claramente peor
    resp_mala = "Francia tiene ciudades. Una de ellas es importante."
    
    def comparar(p1, p2, etiquetas):
        prompt = f"""Pregunta: {pregunta}
Respuesta A: {p1}
Respuesta B: {p2}
¿Cuál es mejor? Responde: A, B, o EMPATE"""
        
        r = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            max_tokens=10
        )
        resultado = r.choices[0].message.content.strip().upper()
        return resultado, etiquetas
    
    # Test 1: Buena primero (esperamos "A")
    r1, e1 = comparar(resp_buena, resp_mala, {"A": "buena", "B": "mala"})
    
    # Test 2: Mala primero (esperamos "B", porque B es la buena)
    r2, e2 = comparar(resp_mala, resp_buena, {"A": "mala", "B": "buena"})
    
    print(f"Test 1 (buena=A, mala=B): Ganador = {r1}")  # Debería ser A
    print(f"Test 2 (mala=A, buena=B): Ganador = {r2}")  # Debería ser B
    
    # Si en Test 2 gana A (la mala), hay position bias
    if r2 == "A":
        print("⚠️ POSITION BIAS DETECTADO: El juez prefirió la respuesta mala cuando estaba primero")
    else:
        print("✓ Sin position bias evidente en este caso")

test_position_bias()

Ejercicio 3: Judge con consenso de 3 llamadas

Implementa un judge que haga 3 llamadas con temperature=0 y solo confíe en el resultado si al menos 2 de 3 coinciden.

Ver solución
from openai import OpenAI
from collections import Counter

client = OpenAI()

def judge_mayoria(pregunta: str, respuesta: str) -> dict:
    """Judge con voto por mayoría (3 llamadas)."""
    scores = []
    
    prompt = f"""Evalúa si esta respuesta es correcta y relevante.
Escala: 1=mala, 2=regular, 3=buena, 4=muy buena, 5=excelente
Solo el número.

Pregunta: {pregunta}
Respuesta: {respuesta}
Score:"""
    
    for _ in range(3):
        r = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0,
            max_tokens=5
        )
        try:
            score = int(r.choices[0].message.content.strip()[0])
            if 1 <= score <= 5:
                scores.append(score)
        except (ValueError, IndexError):
            pass
    
    if not scores:
        return {"score": 3, "confianza": "BAJA", "scores": []}
    
    conteo = Counter(scores)
    score_mayoria = conteo.most_common(1)[0][0]
    votos_mayoria = conteo.most_common(1)[0][1]
    
    return {
        "score": score_mayoria / 5.0,
        "score_raw": score_mayoria,
        "scores": scores,
        "confianza": "ALTA" if votos_mayoria >= 2 else "BAJA",
        "acuerdo": votos_mayoria >= 2
    }

# Test
resultado = judge_mayoria(
    "¿Qué es Python?",
    "Python es un lenguaje de programación de alto nivel, interpretado y multiparadigma."
)
print(resultado)

Resumen

  • Rubrics: Criterios explícitos con escala numérica y ejemplos de calibración (anchoring)
  • Comparison-based: Más confiable que scoring absoluto para comparar variantes de prompt
  • Biases: Length (verbosity), position, self-enhancement, sycophancy — todos mitigables
  • Position bias: Evaluar A vs B y B vs A; solo confiar si coinciden
  • Consenso: Múltiples llamadas o múltiples modelos reducen varianza
  • Validación: Correlacionar con evaluación humana antes de usar en producción
  • Costo: Sampling estratégico para reducir costo en producción

Recursos adicionales

  1. JudgeLM: Fine-Tuned Large Language Models are Scalable Judges — Paper sobre LLM judges
  2. G-Eval — Framework NLG con LLM-as-judge
  3. Judging the Judges: A Systematic Study — Análisis de biases en LLM judges
  4. MT-Bench — Benchmark usando GPT-4 como juez
  5. LangSmith Evaluators — Evaluadores en LangSmith