Módulo 7: Evaluación de Prompts

5. Regression Testing para Prompts

Descripción

Detectar cuando un cambio de prompt rompe casos que antes funcionaban. Implementación de test suites con pytest. CI/CD integration con GitHub Actions. Manejo de baseline scores y alertas de regresión. Estrategias para reducir costos de testing.


¿Qué es Regression Testing para Prompts?

En software tradicional, los regression tests verifican que un bug arreglado no vuelve a aparecer. Para prompts, la regresión es diferente:

Problema típico sin regression testing:
1. Tienes un prompt de clasificación con accuracy 94%
2. Encuentras un edge case nuevo, modificas el prompt
3. El edge case ahora funciona (95% accuracy en ese caso)
4. Pero sin saberlo, rompiste 3 casos que antes funcionaban
5. Accuracy real: 91%
6. Lo descubres cuando los usuarios reportan problemas

Con regression testing:
1. Al modificar el prompt, ejecutas el golden set automáticamente
2. Sistema detecta: accuracy bajó de 94% a 91%
3. Muestra exactamente cuáles casos rompiste
4. No deployar hasta corregir la regresión

Diferencia con Testing de Software Tradicional

AspectoSoftware TradicionalPrompts LLM
Determinismo100% — mismo input = mismo outputNo siempre — mismo input puede variar
Tolerancia0% — test pasa o falla2-5% — pequeñas variaciones son normales
Costo de ejecutarMilisegundos, sin costo variableSegundos, costo por token
Causa de regresiónBug en códigoCambio de prompt, modelo, temperatura
ProfundidadTest unitario exactoEvaluación estadística

Implementación Base

El Motor de Regression Testing

import json
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Callable
from openai import OpenAI

client = OpenAI()

@dataclass
class TestResult:
    ejemplo_id: str
    input: str
    expected: str
    actual: str
    passed: bool
    score: float
    latencia_ms: float
    tokens_usados: int


def run_prompt(prompt_template: str, input_text: str) -> tuple[str, dict]:
    """
    Ejecuta el prompt y retorna (output, metadata).
    metadata incluye latencia y tokens.
    """
    inicio = time.time()
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "user", "content": prompt_template.format(input=input_text)}
        ],
        temperature=0
    )
    
    latencia_ms = (time.time() - inicio) * 1000
    output = response.choices[0].message.content.strip()
    
    metadata = {
        "latencia_ms": latencia_ms,
        "prompt_tokens": response.usage.prompt_tokens,
        "completion_tokens": response.usage.completion_tokens,
        "total_tokens": response.usage.total_tokens
    }
    
    return output, metadata


def evaluar_ejemplo(
    prompt_template: str,
    ejemplo: dict,
    evaluador: Callable | None = None
) -> TestResult:
    """
    Evalúa un solo ejemplo del golden set.
    
    evaluador: función custom. Si no se provee, usa exact match normalizado.
    """
    output, metadata = run_prompt(prompt_template, ejemplo["input"])
    
    if evaluador:
        passed, score = evaluador(ejemplo["expected_output"], output)
    else:
        # Default: exact match normalizado
        expected_norm = str(ejemplo["expected_output"]).strip().lower()
        output_norm = output.strip().lower()
        passed = expected_norm == output_norm
        score = 1.0 if passed else 0.0
    
    return TestResult(
        ejemplo_id=ejemplo["id"],
        input=ejemplo["input"],
        expected=str(ejemplo["expected_output"]),
        actual=output,
        passed=passed,
        score=score,
        latencia_ms=metadata["latencia_ms"],
        tokens_usados=metadata["total_tokens"]
    )

Baseline Management

El baseline es el conjunto de métricas del prompt actual, contra el que se comparan las nuevas versiones.

class BaselineManager:
    """Gestiona los baselines de evaluación para comparación de regresiones."""
    
    def __init__(self, baseline_path: str = "baseline.json"):
        self.baseline_path = Path(baseline_path)
        self._baselines: dict = {}
        
        if self.baseline_path.exists():
            self._cargar()
    
    def _cargar(self) -> None:
        with open(self.baseline_path) as f:
            self._baselines = json.load(f)
        print(f"Baseline cargado: {len(self._baselines)} prompts")
    
    def guardar(self) -> None:
        with open(self.baseline_path, "w") as f:
            json.dump(self._baselines, f, indent=2)
        print(f"Baseline guardado en {self.baseline_path}")
    
    def registrar(self, prompt_name: str, metricas: dict, version: str) -> None:
        """Registra las métricas actuales como nuevo baseline."""
        self._baselines[prompt_name] = {
            "version": version,
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
            "metricas": metricas
        }
        self.guardar()
        print(f"Nuevo baseline registrado para '{prompt_name}' ({version})")
    
    def obtener(self, prompt_name: str) -> dict | None:
        """Obtiene el baseline de un prompt. None si no existe."""
        return self._baselines.get(prompt_name)
    
    def comparar(
        self,
        prompt_name: str,
        metricas_nuevas: dict,
        tolerancia: float = 0.02
    ) -> dict:
        """
        Compara métricas nuevas contra el baseline.
        
        tolerancia: Variación máxima permitida antes de reportar regresión.
                    0.02 = 2% de margen — small variations son normales.
        """
        baseline = self.obtener(prompt_name)
        
        if baseline is None:
            return {
                "status": "NO_BASELINE",
                "mensaje": f"No hay baseline para '{prompt_name}'. Registrar con baseline_manager.registrar()",
                "regressions": [],
                "mejoras": []
            }
        
        metricas_baseline = baseline["metricas"]
        regressions = []
        mejoras = []
        sin_cambio = []
        
        for metrica, valor_nuevo in metricas_nuevas.items():
            if metrica not in metricas_baseline:
                continue
            
            valor_anterior = metricas_baseline[metrica]
            delta = valor_nuevo - valor_anterior
            
            if delta < -tolerancia:
                regressions.append({
                    "metrica": metrica,
                    "anterior": valor_anterior,
                    "nuevo": valor_nuevo,
                    "delta": delta,
                    "severidad": "CRITICA" if delta < -0.05 else "MENOR"
                })
            elif delta > tolerancia:
                mejoras.append({
                    "metrica": metrica,
                    "anterior": valor_anterior,
                    "nuevo": valor_nuevo,
                    "delta": delta
                })
            else:
                sin_cambio.append(metrica)
        
        return {
            "status": "FAIL" if regressions else "PASS",
            "regressions": regressions,
            "mejoras": mejoras,
            "sin_cambio": sin_cambio,
            "baseline_version": baseline["version"],
            "baseline_fecha": baseline["timestamp"]
        }


# Uso:
baseline_mgr = BaselineManager()

# Primera vez: registrar baseline del prompt actual
metricas_actuales = {"accuracy": 0.94, "faithfulness": 0.88, "format": 1.0}
baseline_mgr.registrar("clasificador_sentimiento", metricas_actuales, "v1.0")

# Al cambiar el prompt: comparar
metricas_nuevas = {"accuracy": 0.91, "faithfulness": 0.89, "format": 1.0}
resultado = baseline_mgr.comparar("clasificador_sentimiento", metricas_nuevas)

if resultado["status"] == "FAIL":
    print("❌ REGRESIÓN DETECTADA:")
    for r in resultado["regressions"]:
        print(f"  {r['metrica']}: {r['anterior']:.2%}{r['nuevo']:.2%} ({r['delta']:+.2%}) [{r['severidad']}]")
else:
    print("✅ Sin regresiones")
    for m in resultado["mejoras"]:
        print(f"  📈 {m['metrica']}: {m['anterior']:.2%}{m['nuevo']:.2%} ({m['delta']:+.2%})")

Suite de Tests con pytest

Para integrar con CI/CD, organiza los tests con pytest:

# tests/test_clasificador.py
import pytest
import json
from pathlib import Path
from openai import OpenAI

client = OpenAI()

# ===== FIXTURES =====

@pytest.fixture(scope="session")
def golden_set():
    """Carga el golden set una sola vez por sesión."""
    path = Path("datasets/clasificador_sentimiento.json")
    with open(path) as f:
        return json.load(f)


@pytest.fixture(scope="session")
def baseline():
    """Carga el baseline de métricas."""
    path = Path("baseline.json")
    if not path.exists():
        return {}
    with open(path) as f:
        return json.load(f)


@pytest.fixture(scope="module")
def prompt_actual():
    """Carga el prompt actual desde archivo."""
    with open("prompts/clasificador_v1.txt") as f:
        return f.read()


# ===== TESTS INDIVIDUALES =====

class TestClasificadorSentimiento:
    
    @pytest.mark.parametrize("ejemplo_id,esperado", [
        ("001", "POSITIVO"),
        ("002", "NEGATIVO"),
        ("003", "NEUTRO"),
    ])
    def test_casos_criticos(self, prompt_actual, ejemplo_id, esperado, golden_set):
        """
        Tests que NUNCA deben fallar — los casos más básicos.
        Si estos fallan, hay un problema grave.
        """
        ejemplo = next(e for e in golden_set if e["id"] == ejemplo_id)
        output, _ = run_prompt(prompt_actual, ejemplo["input"])
        
        assert output.strip().upper() == esperado, \
            f"Caso crítico {ejemplo_id} falló: esperado={esperado}, obtenido={output}"
    
    def test_accuracy_minima(self, prompt_actual, golden_set):
        """La accuracy sobre el golden set completo no puede bajar del 85%."""
        outputs = []
        for ej in golden_set:
            output, _ = run_prompt(prompt_actual, ej["input"])
            correcto = output.strip().lower() == str(ej["expected_output"]).strip().lower()
            outputs.append(correcto)
        
        accuracy = sum(outputs) / len(outputs)
        
        assert accuracy >= 0.85, \
            f"Accuracy {accuracy:.2%} por debajo del mínimo aceptable (85%)"
    
    def test_sin_regresion_vs_baseline(self, prompt_actual, golden_set, baseline):
        """Accuracy no puede caer más de 2% respecto al baseline."""
        if "clasificador_sentimiento" not in baseline:
            pytest.skip("No hay baseline registrado — skipping regression test")
        
        baseline_accuracy = baseline["clasificador_sentimiento"]["metricas"]["accuracy"]
        
        outputs = []
        for ej in golden_set:
            output, _ = run_prompt(prompt_actual, ej["input"])
            outputs.append(output.strip().lower() == str(ej["expected_output"]).strip().lower())
        
        accuracy_actual = sum(outputs) / len(outputs)
        
        assert accuracy_actual >= baseline_accuracy - 0.02, \
            f"REGRESIÓN: accuracy bajó de {baseline_accuracy:.2%} a {accuracy_actual:.2%} ({accuracy_actual - baseline_accuracy:+.2%})"
    
    def test_format_compliance(self, prompt_actual, golden_set):
        """El output debe ser siempre: POSITIVO, NEGATIVO, o NEUTRO."""
        outputs_invalidos = []
        
        for ej in golden_set[:20]:  # Solo primeros 20 para rapidez
            output, _ = run_prompt(prompt_actual, ej["input"])
            if output.strip().upper() not in ["POSITIVO", "NEGATIVO", "NEUTRO"]:
                outputs_invalidos.append({"id": ej["id"], "output": output})
        
        assert len(outputs_invalidos) == 0, \
            f"Outputs con formato inválido: {outputs_invalidos}"
    
    def test_edge_cases(self, prompt_actual, golden_set):
        """Los edge cases deben tener accuracy >= 70%."""
        edge_cases = [e for e in golden_set if e.get("dificultad") in ["dificil", "muy_dificil"]]
        
        if not edge_cases:
            pytest.skip("No hay edge cases en el golden set")
        
        outputs = []
        for ej in edge_cases:
            output, _ = run_prompt(prompt_actual, ej["input"])
            outputs.append(output.strip().lower() == str(ej["expected_output"]).strip().lower())
        
        accuracy = sum(outputs) / len(outputs)
        
        assert accuracy >= 0.70, \
            f"Accuracy en edge cases ({accuracy:.2%}) por debajo de 70%"
    
    def test_latencia_aceptable(self, prompt_actual):
        """La latencia promedio debe ser < 3 segundos."""
        import time
        
        test_input = "Este es un producto excelente"
        latencias = []
        
        for _ in range(3):
            inicio = time.time()
            run_prompt(prompt_actual, test_input)
            latencias.append(time.time() - inicio)
        
        latencia_promedio = sum(latencias) / len(latencias)
        
        assert latencia_promedio < 3.0, \
            f"Latencia promedio {latencia_promedio:.1f}s supera el límite de 3s"

Integración con CI/CD (GitHub Actions)

# .github/workflows/prompt-regression-tests.yml
name: Prompt Regression Tests

on:
  pull_request:
    paths:
      - 'prompts/**'      # Solo cuando cambian los prompts
      - 'datasets/**'     # O cuando cambia el golden set
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *'  # Daily a las 2am (para detectar drift del modelo)

jobs:
  regression-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Cache dependencies
        uses: actions/cache@v3
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
      
      - name: Install dependencies
        run: pip install -r requirements.txt
      
      - name: Run critical tests (fast)
        run: pytest tests/test_clasificador.py::TestClasificadorSentimiento::test_casos_criticos -v
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      
      - name: Run regression tests
        run: |
          pytest tests/test_clasificador.py -v \
            --tb=short \
            --junitxml=test-results.xml \
            -k "not test_latencia"  # Excluir tests de latencia en CI (inestables)
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      
      - name: Upload test results
        uses: actions/upload-artifact@v3
        if: always()
        with:
          name: test-results
          path: test-results.xml
      
      - name: Comment PR with results
        if: github.event_name == 'pull_request'
        uses: EnricoMi/publish-unit-test-result-action@v2
        with:
          files: test-results.xml

Estrategias para Reducir Costos de Testing

El costo de ejecutar regression tests en CI puede ser significativo. Estrategias para reducirlo:

Estrategia 1: Tiers de Tests

# conftest.py — Configuración de pytest
import pytest

def pytest_addoption(parser):
    parser.addoption("--test-tier", action="store", default="smoke",
                     help="Test tier: smoke | standard | full")

@pytest.fixture(scope="session")
def test_tier(request):
    return request.config.getoption("--test-tier")


# En tests:
@pytest.fixture
def golden_set_por_tier(test_tier):
    """Retorna subset del golden set según el tier."""
    with open("datasets/golden_set.json") as f:
        todos = json.load(f)
    
    if test_tier == "smoke":
        # Solo casos críticos: rápido y barato
        return [e for e in todos if e.get("critico", False)][:10]
    elif test_tier == "standard":
        # Happy path + edge cases: balance costo/cobertura
        return [e for e in todos if e.get("dificultad") in ["facil", "dificil"]][:50]
    else:  # full
        return todos
# En CI (PRs): solo smoke tests
pytest tests/ --test-tier=smoke

# En CI (merge to main): tests estándar
pytest tests/ --test-tier=standard

# Overnight: suite completa
pytest tests/ --test-tier=full

Estrategia 2: Caché de Resultados

import hashlib
import json
from pathlib import Path

class TestCache:
    """Caché de resultados de tests para evitar re-ejecutar tests sin cambios."""
    
    def __init__(self, cache_dir: str = ".test_cache"):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(exist_ok=True)
    
    def cache_key(self, prompt: str, input_text: str, model: str = "gpt-4o-mini") -> str:
        content = f"{prompt}|{input_text}|{model}"
        return hashlib.sha256(content.encode()).hexdigest()[:16]
    
    def get(self, prompt: str, input_text: str) -> str | None:
        key = self.cache_key(prompt, input_text)
        path = self.cache_dir / f"{key}.json"
        
        if path.exists():
            with open(path) as f:
                return json.load(f)["output"]
        return None
    
    def set(self, prompt: str, input_text: str, output: str) -> None:
        key = self.cache_key(prompt, input_text)
        path = self.cache_dir / f"{key}.json"
        
        with open(path, "w") as f:
            json.dump({"output": output, "timestamp": time.time()}, f)
    
    def stats(self) -> dict:
        files = list(self.cache_dir.glob("*.json"))
        return {"cached_results": len(files), "cache_dir": str(self.cache_dir)}


cache = TestCache()

def run_prompt_cached(prompt: str, input_text: str) -> str:
    """Versión cacheada de run_prompt para tests."""
    cached = cache.get(prompt, input_text)
    if cached:
        return cached
    
    output, _ = run_prompt(prompt, input_text)
    cache.set(prompt, input_text, output)
    return output

Estrategia 3: Paralelización

# pytest-xdist para paralelizar tests
# pip install pytest-xdist

# En CI:
# pytest tests/ -n 4  # 4 workers en paralelo

# En el código de tests, usar asyncio para paralelizar las llamadas API:
import asyncio
from openai import AsyncOpenAI

async_client = AsyncOpenAI()

async def run_prompt_async(prompt_template: str, input_text: str) -> tuple[str, dict]:
    """Versión async de run_prompt."""
    response = await async_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt_template.format(input=input_text)}],
        temperature=0
    )
    return response.choices[0].message.content.strip(), {}


async def evaluar_golden_set_async(
    prompt_template: str,
    golden_set: list[dict],
    max_concurrent: int = 10
) -> list[dict]:
    """Evalúa el golden set en paralelo con límite de concurrencia."""
    semaphore = asyncio.Semaphore(max_concurrent)
    
    async def evaluar_con_limite(ejemplo):
        async with semaphore:
            output, _ = await run_prompt_async(prompt_template, ejemplo["input"])
            return {
                "id": ejemplo["id"],
                "expected": str(ejemplo["expected_output"]),
                "actual": output,
                "passed": output.strip().lower() == str(ejemplo["expected_output"]).strip().lower()
            }
    
    tasks = [evaluar_con_limite(ej) for ej in golden_set]
    return await asyncio.gather(*tasks)

# Ejecutar:
# resultados = asyncio.run(evaluar_golden_set_async(prompt, golden_set))

Reporte de Regresión

def generar_reporte_regresion(
    prompt_name: str,
    resultados: list[dict],
    baseline_metricas: dict | None = None
) -> str:
    """
    Genera un reporte markdown de los resultados del regression test.
    Útil para comentar en PRs o enviar por Slack.
    """
    pasados = sum(1 for r in resultados if r["passed"])
    fallidos = len(resultados) - pasados
    accuracy = pasados / len(resultados)
    
    lineas = [
        f"# Regression Test: {prompt_name}",
        f"",
        f"**Fecha:** {time.strftime('%Y-%m-%d %H:%M:%S')}",
        f"",
        f"## Resumen",
        f"| Métrica | Valor |",
        f"|---------|-------|",
        f"| Total ejemplos | {len(resultados)} |",
        f"| Pasados | {pasados} ✅ |",
        f"| Fallidos | {fallidos} {'❌' if fallidos > 0 else '✅'} |",
        f"| Accuracy | {accuracy:.2%} |",
    ]
    
    # Comparación con baseline
    if baseline_metricas and "accuracy" in baseline_metricas:
        baseline_acc = baseline_metricas["accuracy"]
        delta = accuracy - baseline_acc
        estado = "✅ PASS" if delta >= -0.02 else "❌ REGRESSION"
        lineas.extend([
            f"| Baseline accuracy | {baseline_acc:.2%} |",
            f"| Delta vs baseline | {delta:+.2%} |",
            f"| Estado | {estado} |",
        ])
    
    # Casos fallidos
    if fallidos > 0:
        lineas.extend([
            f"",
            f"## Casos Fallidos ({fallidos})",
            f"",
        ])
        
        for r in resultados:
            if not r["passed"]:
                input_corto = str(r.get("input", ""))[:60]
                lineas.extend([
                    f"### ❌ ID: {r['id']}",
                    f"- **Input:** `{input_corto}...`",
                    f"- **Esperado:** `{r['expected']}`",
                    f"- **Obtenido:** `{r['actual']}`",
                    f"",
                ])
    
    return "\n".join(lineas)

Troubleshooting

Problema 1: Falsos positivos en regression tests

Síntoma: Los tests fallan aunque el prompt no cambió.

Causa: Variabilidad natural del modelo, incluso con temperature=0.

Solución:

# Aumentar la tolerancia del baseline comparison
resultado = baseline_mgr.comparar(
    "clasificador",
    metricas_nuevas,
    tolerancia=0.03  # 3% en lugar de 2%
)

# O ejecutar múltiples veces y promediar
def accuracy_promedio(prompt, golden_set, n_runs=3):
    accuracies = []
    for _ in range(n_runs):
        outputs = [run_prompt(prompt, e["input"])[0] for e in golden_set]
        acc = sum(o.strip().lower() == str(e["expected_output"]).strip().lower()
                  for o, e in zip(outputs, golden_set)) / len(golden_set)
        accuracies.append(acc)
    return sum(accuracies) / len(accuracies)

Problema 2: Golden set demasiado pequeño

Síntoma: Con 20 ejemplos, un solo fallo cambia la accuracy en 5%.

Causa: 20 ejemplos = demasiada varianza para detectar cambios reales vs ruido.

Solución:

# Calcular el tamaño mínimo necesario
def margen_error_actual(n: int, accuracy: float = 0.9, confianza: float = 0.95) -> float:
    """Calcula el margen de error con n ejemplos."""
    from scipy.stats import norm
    z = norm.ppf((1 + confianza) / 2)
    return z * (accuracy * (1 - accuracy) / n) ** 0.5

# n=20:  margen ≈ ±13%  — demasiado amplio
# n=100: margen ≈ ±6%   — aceptable
# n=400: margen ≈ ±3%   — bueno

print(f"n=20:  ±{margen_error_actual(20):.0%}")
print(f"n=100: ±{margen_error_actual(100):.0%}")
print(f"n=400: ±{margen_error_actual(400):.0%}")

Problema 3: Costo excesivo en CI

Síntoma: Cada PR cuesta $5-10 en llamadas API.

Solución:

# 1. Usar smoke tests pequeños en PRs (10-20 ejemplos críticos)
# 2. Full suite solo en merges a main
# 3. Caché de resultados para prompts que no cambiaron
# 4. Rate limiting para reducir costo

import time

def run_con_rate_limit(
    prompt_template: str,
    golden_set: list[dict],
    requests_per_minute: int = 30
) -> list[dict]:
    """Ejecuta el golden set con rate limiting."""
    delay = 60.0 / requests_per_minute  # segundos entre requests
    resultados = []
    
    for i, ejemplo in enumerate(golden_set):
        if i > 0:
            time.sleep(delay)
        
        output, metadata = run_prompt(prompt_template, ejemplo["input"])
        resultados.append({
            "id": ejemplo["id"],
            "output": output,
            "tokens": metadata["total_tokens"]
        })
    
    return resultados

Ejercicios

Ejercicio 1: Escribir tu primer regression test

Escribe un test que verifique que el siguiente prompt de clasificación mantiene accuracy >= 88%:

PROMPT_CLASIFICADOR = """Clasifica el siguiente texto como POSITIVO, NEGATIVO, o NEUTRO.
Responde solo con la categoría.

Texto: {input}
Categoría:"""
Ver solución
import pytest
import json
from openai import OpenAI

client = OpenAI()

PROMPT_CLASIFICADOR = """Clasifica el siguiente texto como POSITIVO, NEGATIVO, o NEUTRO.
Responde solo con la categoría.

Texto: {input}
Categoría:"""

@pytest.fixture
def golden_set_sentimiento():
    return [
        {"id": "001", "input": "Excelente producto, muy recomendado", "expected_output": "POSITIVO"},
        {"id": "002", "input": "Terrible, jamás compraría de nuevo", "expected_output": "NEGATIVO"},
        {"id": "003", "input": "El paquete llegó a tiempo", "expected_output": "NEUTRO"},
        {"id": "004", "input": "Increíble calidad, superó mis expectativas", "expected_output": "POSITIVO"},
        {"id": "005", "input": "Muy mala experiencia de compra", "expected_output": "NEGATIVO"},
        {"id": "006", "input": "El producto viene en caja azul", "expected_output": "NEUTRO"},
        {"id": "007", "input": "¡Me encanta!", "expected_output": "POSITIVO"},
        {"id": "008", "input": "Fraude total, no sirve para nada", "expected_output": "NEGATIVO"},
    ]

def test_accuracy_minima(golden_set_sentimiento):
    """Accuracy no puede bajar del 88%."""
    correctos = 0
    
    for ejemplo in golden_set_sentimiento:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": PROMPT_CLASIFICADOR.format(input=ejemplo["input"])}],
            temperature=0
        )
        output = response.choices[0].message.content.strip().upper()
        if output == ejemplo["expected_output"].upper():
            correctos += 1
    
    accuracy = correctos / len(golden_set_sentimiento)
    assert accuracy >= 0.88, f"Accuracy {accuracy:.2%} por debajo del mínimo"
    print(f"✅ Accuracy: {accuracy:.2%}")

def test_formato_valido(golden_set_sentimiento):
    """El output debe ser siempre POSITIVO, NEGATIVO, o NEUTRO."""
    categorias_validas = {"POSITIVO", "NEGATIVO", "NEUTRO"}
    invalidos = []
    
    for ejemplo in golden_set_sentimiento[:5]:  # Solo 5 para rapidez
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": PROMPT_CLASIFICADOR.format(input=ejemplo["input"])}],
            temperature=0
        )
        output = response.choices[0].message.content.strip().upper()
        if output not in categorias_validas:
            invalidos.append({"id": ejemplo["id"], "output": output})
    
    assert len(invalidos) == 0, f"Outputs con formato inválido: {invalidos}"

Ejercicio 2: Implementar baseline comparison

Crea una función que compare métricas actuales contra un baseline y retorne si hay regresión:

Ver solución
def detectar_regresion(
    metricas_nuevas: dict[str, float],
    metricas_baseline: dict[str, float],
    tolerancia: float = 0.02
) -> dict:
    """
    Detecta regresiones comparando métricas actuales contra baseline.
    Retorna: {"hay_regresion": bool, "regressions": list, "mejoras": list}
    """
    regressions = []
    mejoras = []
    
    for metrica, valor_nuevo in metricas_nuevas.items():
        if metrica not in metricas_baseline:
            continue
        
        valor_anterior = metricas_baseline[metrica]
        delta = valor_nuevo - valor_anterior
        
        if delta < -tolerancia:
            regressions.append({
                "metrica": metrica,
                "anterior": f"{valor_anterior:.2%}",
                "nuevo": f"{valor_nuevo:.2%}",
                "delta": f"{delta:+.2%}",
                "severidad": "CRITICA" if delta < -0.05 else "MENOR"
            })
        elif delta > tolerancia:
            mejoras.append({
                "metrica": metrica,
                "delta": f"{delta:+.2%}"
            })
    
    return {
        "hay_regresion": len(regressions) > 0,
        "regressions": regressions,
        "mejoras": mejoras,
        "resumen": f"{'❌ REGRESIÓN' if regressions else '✅ OK'}: {len(regressions)} regressions, {len(mejoras)} mejoras"
    }

# Test
baseline = {"accuracy": 0.94, "format": 1.0, "faithfulness": 0.88}
nuevas = {"accuracy": 0.91, "format": 1.0, "faithfulness": 0.91}

resultado = detectar_regresion(nuevas, baseline)
print(resultado["resumen"])
# ❌ REGRESIÓN: 1 regressions, 1 mejoras

Resumen

  • Regression testing: Detectar cuando un cambio de prompt rompe casos que antes funcionaban
  • Baseline: Guardar métricas del prompt actual; comparar después de cada cambio
  • pytest: Framework natural para organizar tests de prompts; con fixtures y parametrize
  • CI/CD: GitHub Actions ejecuta tests en cada PR o push
  • Tolerancia: 2-3% de margen para variación natural del modelo
  • Tiers: Smoke (barato, rápido) → Standard → Full (caro, completo)
  • Caché: Reutilizar resultados de tests cuando el prompt no cambió
  • Paralelización: Async para acelerar la ejecución del golden set

Recursos adicionales

  1. pytest Documentation — Framework de testing
  2. GitHub Actions — CI/CD
  3. pytest-xdist — Paralelización de tests
  4. OpenAI Rate Limits — Límites de API para planificar tests
  5. LangSmith Regression Testing — Alternativa managed