Deja de escribir casos de prueba a mano: genera un conjunto de evaluación con la API de WEC
Tu evaluación pasa el 100% — de los dos casos de prueba que escribiste a mano. Los usuarios reales no expresarán las cosas como tú lo hiciste.
En parte 1 y parte 2 construimos un arnés de evaluación real — afirmaciones, validación de esquema, una matriz de modelos. Pero dos casos no pueden decirte si tu aplicación funciona; solo pueden decirte que no se bloqueó con dos entradas.
Esta guía soluciona eso. La habilidad es producir un conjunto de datos en el que realmente puedas confiar: usamos la API de Inferencia de WEC para generar tickets etiquetados, luego validarlos y curarlos — porque las etiquetas generadas no son automáticamente correctas, y tratarlas como verdad solo mueve el error. El resultado es datos de prueba reales a gran escala. Cuando finalmente lo ejecutes a través del arnés de la parte 2, la cobertura revela las fallas — malas clasificaciones genuinas y etiquetas debatibles — que dos casos seleccionados a mano ocultan.
Cómo se integra todo
Cada paso alimenta al siguiente, terminando en un conjunto de datos que se conecta directamente a la evaluación de la parte 2:
Requisitos previos
-
Una clave de API de Inferencia de WEC — consulta Inferencia → Claves de API.
-
jq,curl, y Node.js 22+ (Promptfoo se ejecuta a través denpx). -
Tu clave exportada y saneada (una clave pegada a menudo lleva caracteres invisibles):
export WEC_API_KEY='sk-your-key'export WEC_API_KEY=$(printf '%s' "$WEC_API_KEY" | LC_ALL=C tr -cd '[:print:]')
Si una llamada devuelve un 401 / "clave de API inválida", tu clave ha expirado o ha sido revocada — genera una
nueva en Inferencia → Claves de API. Consulta Solución de problemas.
Paso 1 — Generar un lote etiquetado
Pide al modelo tickets con sus respuestas — las etiquetas son lo que lo convierte en un conjunto de datos, no
solo entradas. Reutilizamos el esquema exacto de la parte 2 (category + priority enums):
curl -s https://inference.wiline.com/v1/chat/completions \
-H "Authorization: Bearer $WEC_API_KEY" -H "Content-Type: application/json" \
-d '{
"model": "Qwen2.5-3B-Instruct",
"temperature": 0.7,
"messages": [{"role":"user","content":"Genera 8 tickets de soporte al cliente diversos y realistas para una empresa de alojamiento en la nube. Devuelve SOLO JSONL (un objeto JSON por línea), cada uno con claves: ticket (cadena), category (uno de: billing, technical, account, other), priority (uno de: low, medium, high). Sin markdown, sin cercas."}]
}' | jq -r '.choices[0].message.content'
{"ticket":"Nuestro servidor se cayó inesperadamente y no estamos seguros de qué lo causó.","category":"technical","priority":"high"}
{"ticket":"Necesitamos actualizar nuestro plan pero el sistema muestra un mensaje de error al intentar cambiar de planes.","category":"account","priority":"medium"}
{"ticket":"Estamos teniendo problemas para acceder a nuestra base de datos y no podemos iniciar sesión.","category":"technical","priority":"high"}
... 5 líneas más ...
Figura 1. Una única generación: 8 tickets diversos, cada uno ya llevando su etiqueta de category y priority.
JSONL limpio, etiquetas válidas, verdadera diversidad — y regresó en segundos. Elegimos deliberadamente un
modelo pequeño y no razonador (Qwen2.5-3B-Instruct) para la generación. Aquí está el porqué de su importancia.
Los modelos razonadores devuelven su cadena de pensamiento en un campo separado reasoning_content — tokens
por los que pagas pero que nunca usas. Pide a un modelo razonador como Qwen3.5:9B algunos tickets y compara el
razonamiento oculto con la respuesta real:
curl -s https://inference.wiline.com/v1/chat/completions \
-H "Authorization: Bearer $WEC_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"Qwen3.5:9B","temperature":0.7,"messages":[{"role":"user","content":"Genera 3 tickets de soporte al cliente realistas para una empresa de alojamiento en la nube. Devuelve SOLO JSONL, claves: ticket, category, priority. Sin markdown, sin cercas."}]}' \
| jq '{model, completion_tokens: .usage.completion_tokens, reasoning_chars: (.choices[0].message.reasoning_content|length), answer_chars: (.choices[0].message.content|length)}'
{
"model": "Qwen3.5:9B",
"completion_tokens": 2000,
"reasoning_chars": 7116,
"answer_chars": 412
}
Figura 1b. Para solo 3 tickets, Qwen3.5:9B escribió 7,116 caracteres de razonamiento oculto para
producir 412 de respuesta — pagas por todos los ~2,000 tokens de finalización. (Los conteos varían por llamada; la
asimetría no.) Por eso generamos con el pequeño y no razonador Qwen2.5-3B — y es la misma razón por la que esos modelos razonadores tropiezan con la salida estructurada en la matriz de la Parte 5.
Paso 2 — Valida antes de confiar en ello
Nunca trates los datos generados como verdad absoluta sin verificarlos. Guarda un lote y valida tres cosas: cada línea se analiza, cada etiqueta está en el enum, y las clases están razonablemente equilibradas.
curl -s https://inference.wiline.com/v1/chat/completions \
-H "Authorization: Bearer $WEC_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"Qwen2.5-3B-Instruct","temperature":0.7,"messages":[{"role":"user","content":"Genera 8 tickets de soporte al cliente diversos y realistas para una empresa de alojamiento en la nube. Devuelve SOLO JSONL (un objeto JSON por línea), cada uno con claves: ticket (cadena), category (uno de: billing, technical, account, other), priority (uno de: low, medium, high). Sin markdown, sin cercas."}]}' \
| jq -r '.choices[0].message.content' > tickets.jsonl
# 1) ¿todas las líneas se analizan como JSON?
jq -e . tickets.jsonl > /dev/null && echo "todas las líneas son JSON válidos" || echo "JSON INVÁLIDO presente"
# 2) ¿hay etiquetas fuera del enum? (sin filas impresas = todas válidas)
jq -c 'select((.category|IN("billing","technical","account","other")|not) or (.priority|IN("low","medium","high")|not))' tickets.jsonl
# 3) distribución de clases
jq -rs 'group_by(.category)[] | "\(.[0].category): \(length)"' tickets.jsonl
todas las líneas son JSON válidos
account: 2
billing: 2
other: 1
technical: 3
Figura 2. Salida de validación — cada línea se analiza, sin etiquetas fuera del enum, y una distribución de clases razonable.
El intuitivo ["billing",...] | index(.category) falla con "No se puede indexar un array con
cadena" — dentro del pipe, . es el array, así que .category intenta indexarlo. Usa
.category | IN("billing", ...) en su lugar. Consulta Solución de problemas.
Paso 3 — Escalar y eliminar duplicados
Un lote de 8 no es suficiente. Repite la llamada varias veces (temperatura más alta para variedad entre lotes), concatena, y luego elimina duplicados por texto del ticket:
for i in $(seq 1 5); do
curl -s https://inference.wiline.com/v1/chat/completions \
-H "Authorization: Bearer $WEC_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"Qwen2.5-3B-Instruct","temperature":0.9,"messages":[{"role":"user","content":"Genera 8 tickets de soporte al cliente diversos y realistas para una empresa de alojamiento en la nube. Devuelve SOLO JSONL (un objeto JSON por línea), cada uno con claves: ticket (cadena), category (uno de: billing, technical, account, other), priority (uno de: low, medium, high). Sin markdown, sin cercas."}]}' \
| jq -r '.choices[0].message.content // empty'
echo "lote $i hecho" >&2
done > tickets_raw.jsonl
# eliminar duplicados por texto del ticket
jq -sc 'unique_by(.ticket)[]' tickets_raw.jsonl > tickets_dataset.jsonl
echo "crudo: $(wc -l < tickets_raw.jsonl) → deduplicado: $(wc -l < tickets_dataset.jsonl)"
crudo: 40 → deduplicado: 40
Figura 3. Escalando: 5 lotes → 40 filas después de la eliminación de duplicados exactos.
Cero duplicados exactos en 5 lotes — una buena señal de que el modelo no se está repitiendo.
unique_by(.ticket) solo captura texto idéntico. Dos tickets "el servidor está caído" redactados de manera diferente son duplicados semánticos y se deslizarán — capturarlos requiere agrupamiento basado en incrustaciones, un paso más avanzado. Para un conjunto de datos inicial, la deduplicación exacta está bien.
Nota el // empty en el filtro jq. Si una llamada falla (clave revocada, tiempo de espera del modelo), la
respuesta no tiene contenido y .choices[0].message.content es null — un simple jq -r escribiría la palabra literal null en tu conjunto de datos, envenenándolo silenciosamente. // empty elimina esos,
así que una llamada fallida agrega nada en lugar de una fila basura. Siempre verifica el conteo de filas después.
Consulta Solución de problemas.
Valida las 40 completas de la misma manera que en el Paso 2 (jq -e ., la verificación del enum, y ambas distribuciones).
Las nuestras resultaron equilibradas: técnico 15 · cuenta 10 · facturación 10 · otro 5; prioridad alta 13 ·
media 14 · baja 13.
Ahora tienes un pipeline repetible para datos de prueba reales — 40 filas etiquetadas, validadas y deduplicadas donde antes tenías dos.
Paso 4 — Convertir a un conjunto de datos de Promptfoo
Promptfoo lee casos de prueba desde un CSV: cada columna se convierte en una variable, y la columna especial
__expected contiene una afirmación por fila. Mapeamos ticket → la variable de prompt y
__expected → icontains:<la categoría correcta>. jq @csv maneja las comas y comillas
dentro del texto del ticket:
{ echo 'ticket,__expected'; jq -r '[.ticket, ("icontains:" + .category)] | @csv' tickets_dataset.jsonl; } > tests.csv
wc -l tests.csv # 41 = encabezado + 40 filas
Paso 5 — Poner el conjunto de datos a trabajar
El conjunto de datos es el entregable — todo desde aquí es lo que desbloquea. Primer beneficio: una comparación de modelos en la que realmente puedes confiar. En dos casos escritos a mano, un ranking es ruido; en 40 filas reales, etiquetadas es una medición. Agrega cada modelo WEC como proveedor y ejecuta todo el conjunto:
cat > promptfooconfig.yaml <<'EOF'
description: "Clasificador de tickets — matriz de modelos sobre el conjunto de datos sintético de 40 filas"
providers:
- id: openai:chat:Qwen2.5-3B-Instruct
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:Qwen3.5:9B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
- id: openai:chat:Qwen3.5-122B
config: { apiBaseUrl: https://inference.wiline.com/v1, apiKeyEnvar: WEC_API_KEY, temperature: 0 }
prompts:
- |
Clasifica este ticket de soporte. Devuelve SOLO JSON con claves:
"category" (uno de: billing, technical, account, other),
"priority" (uno de: low, medium, high),
"summary" (cadena, máximo 12 palabras).
Ticket: {{ticket}}
defaultTest:
options:
transform: |
const m = output.match(/\{[\s\S]*?\}/);
return m ? m[0] : output;
assert:
- type: is-json
value:
type: object
required: [category, priority, summary]
properties:
category: { type: string, enum: [billing, technical, account, other] }
priority: { type: string, enum: [low, medium, high] }
summary: { type: string }
tests: file://tests.csv
EOF
npx -y promptfoo@latest eval -c promptfooconfig.yaml
El resultado es lo opuesto a lo que "más grande es mejor" predeciría:
| Modelo | Precisión | Tokens de finalización / ticket |
|---|---|---|
Qwen2.5-3B-Instruct (no razonador) | 85% (34/40) | ~30 |
Qwen3.5:9B (razonador) | 47.5% (19/40) | ~965 |
Qwen3.5-122B (razonador) | 15% (6/40) | ~1,013 |
Figura 4. Mismo conjunto de datos, tres modelos — el pequeño modelo no razonador gana por mucho.
El pequeño modelo no razonador gana decisivamente — y cuanto más grande es el modelo razonador, peor
lo hace. Los modelos razonadores no son más tontos al clasificar; enterraron el JSON en su
cadena de pensamiento. Qwen3.5:9B filtra Thinking: … en la salida; Qwen3.5-122B a menudo devuelve
una respuesta vacía por completo — todos los ~1,000 tokens gastados razonando, nada parseable queda. También
queman ~30× más tokens por ticket, razón por la cual la matriz completa tomó 12m 31s, casi todo
el tiempo esperando a los dos grandes modelos. Para salida estructurada, pensar en voz alta es una desventaja,
no una ventaja — un hallazgo contraintuitivo que nunca obtendrías de una tabla de clasificación.
Pero nota lo que realmente hizo posible ese hallazgo: el conjunto de datos. Con dos casos escritos a mano esos porcentajes serían lanzamientos de moneda; 40 filas etiquetadas son lo que convierte "¿qué modelo?" de una suposición en una medición. La comparación es la recompensa por construir los datos — no un sustituto de ellos.
Ahora puedes elegir un modelo con evidencia en lugar de sensaciones — algo que ninguna tabla de clasificación puede hacer por ti, porque no conoce tu tarea.
(El transform captura el primer bloque {…} con una coincidencia no codiciosa *? — un seguro barato para
modelos que envuelven JSON en prosa, exactamente como en parte 2.)
category aquí — priority es tu próxima afirmaciónLa columna __expected califica solo la categoría predicha contra la etiqueta. El conjunto de datos también
lleva una etiqueta de priority que aún no calificamos. Para calificar ambos campos a la vez, agrega una
afirmación de javascript que analice la salida JSON y compare también priority — el mismo patrón exacto, una verificación más.
Calificar más de lo que generaste es la forma más barata de hacer una evaluación más estricta.
Paso 6 — Profundiza en las fallas del ganador
Toma el ganador — Qwen2.5-3B con 85% (34/40) — y extrae sus 6 tickets fallidos, comparando cada uno
con su etiqueta. (__expected no se almacena como una variable — Promptfoo lo consume como la afirmación —
así que lee la verdad de la fuente de datos). Debido a que los datos son recién generados, tus fallas exactas
diferirán, pero se dividen consistentemente en dos tipos reconocibles — y ninguno es solo "el modelo es tonto". Ejemplos representativos:
Figura 5. El informe web facilita escanear qué filas fallaron y abrir cada una.
| Ticket | Etiqueta | Por qué es complicado |
|---|---|---|
| "descuentos educativos para startups…" | other | Debatible — se trata de precios, así que billing es defensible |
| "otorgar acceso IAM a S3 a un desarrollador junior…" | account | Debatible — control de acceso vs infraestructura |
| "comentarios sobre la documentación de la API…" | other | Debatible — comentarios vs technical |
| "actualizar la tarjeta y agregar un usuario…" | account | Multi-intención — genuinamente dos categorías a la vez |
El resto son malas clasificaciones ordinarias. Las lecciones reales, del día a día:
- Tus etiquetas sintéticas no son verdad absoluta. Un bloque de "fallas" son disputas de etiquetas — revísalas y corrígelas, o tu evaluación mide lo incorrecto.
- La clasificación de una sola etiqueta falla en tickets de múltiples intenciones. Las entradas reales no siempre son de una categoría.
- Un 85% de encabezado oculta ambas. Solo aprendes por qué leyendo las filas que fallaron — que es la razón completa para evaluar en un conjunto de datos real en lugar de en dos ejemplos.
Lo que construimos
- La habilidad central: un pipeline repetible para generar, validar, deduplicar y versionar un conjunto de datos de evaluación desde la API de WEC — datos de prueba reales en lugar de dos casos escritos a mano.
- La disciplina que lo hace confiable: las etiquetas generadas no son verdad absoluta. Validas enums y equilibrio, y revisas las disputas — un bloque de "fallas" son tus propias etiquetas siendo incorrectas.
- Prueba de que importa: la cobertura revela lo que dos casos ocultan — malas clasificaciones genuinas y tickets debatibles y de múltiples intenciones que nunca pensarías en escribir a mano.
- Un bono que los datos desbloquean: una comparación de modelos confiable — aquí un modelo no razonador de 3B superó a uno razonador de 122B para salida estructurada, lo opuesto a "más grande es mejor."
Compromete tickets_dataset.jsonl y tests.csv junto a tu configuración de evaluación, y regenera/crece
el conjunto de datos a medida que tu producto cambia.
Solución de problemas
401 / clave de API inválida
{ "error": { "message": "Error de autenticación, clave de API inválida", "code": "401" } }
La clave ha expirado, ha sido revocada o está mal formada. Genera una nueva en Inferencia → Claves de API,
reexporta y vuelve a sanear (tr -cd '[:print:]') en caso de que el pegado haya llevado un carácter invisible.
jq: "No se puede indexar un array con cadena"
Esto proviene de ["billing", ...] | index(.category) — dentro del pipe, . es el array, así que
.category indexa el array. Usa el idiom IN() en su lugar:
jq -c 'select((.category|IN("billing","technical","account","other")|not))' tickets.jsonl
Figura 6. El error de index(.field) — dentro del pipe, jq intenta indexar el array con una cadena.
Tu conjunto de datos tiene filas que son solo null
Una fila null significa que una llamada de generación falló pero el bucle aún escribió su resultado (vacío). Causas habituales: una clave de API expirada/revocada, o el modelo que se agota. Soluciones: extraer con
.choices[0].message.content // empty para que una llamada fallida escriba nada en lugar de null;
vuelve a verificar wc -l después de cada ejecución de generación; y si las llamadas se cuelgan, agrega un tiempo de espera por solicitud
(curl -m 60) y considera un modelo más ligero y rápido para la generación masiva.
__expected es null en el JSON de resultados
Eso es esperado — Promptfoo consume la columna __expected del CSV como la afirmación de la fila, así que
no se mantiene como una variable. Para inspeccionar las etiquetas de verdad para las filas que fallan, léelas de tu
conjunto de datos fuente (tickets_dataset.jsonl) en lugar del archivo de resultados.
¿Qué sigue?
Ahora tienes un conjunto de datos real — pero se ejecuta en CI, fuera de línea. El siguiente paso es observar tu aplicación en producción: autoalojar Langfuse para rastrear cada llamada, seguir la latencia y el costo, y ejecutar evaluaciones (usando este conjunto de datos) contra el tráfico en vivo. Ahí es donde las pruebas fuera de línea se convierten en una verdadera observabilidad — y es la próxima publicación de la serie.
