Ningún cambio de prompt entra sin un número que lo respalde.
No es una app nueva: es el mismo agente del Artefacto 4, pero con disciplina.
Prompts versionados, un eval-set de bugs reales emparejados con casos de control,
un juez LLM para lo que ningún if mide, structured outputs validados —
y, desde el anexo, una puerta que corre sola en cada push y un canario que vigila el modelo.
De «a ojo» a disciplina medida
El Artefacto 4 funcionaba, pero sus prompts se afinaron a ojo: escribir, probar una vez, y si colaba, a producción. Durante su construcción cacé tres bugs de prompt reales y los parcheé sobre la marcha metiendo frases. Funcionaban… pero nunca medí si funcionaban siempre. Este artefacto convierte ese «a ojo» en un proceso: esos tres bugs se vuelven el eval-set perfecto, precisamente porque son reales.
Una báscula que mide las dos caras
El eval-set va en parejas: cada bug (un fallo real) tiene su control (el daño de pasarse de frenada al arreglarlo). El bug mide si mejoras; el control, si rompes. Sin el control, la tabla te miente con buena cara.
Cada fila guarda el hash del prompt que se usó de verdad: si alguien edita una versión publicada en vez de crear otra, el hash cambia y las ejecuciones viejas dejan de cuadrar. La regla «una versión no se edita» pasa de comentario a comprobable. Y guardar la salida cruda —no un resumen— tuvo premio diferido: cuando más tarde hubo que mover el listón, se pudieron re-puntuar las 13 ejecuciones de la historia sin gastar una sola llamada.
La muestra pequeña casi invierte la verdad
Al comparar la carta v1 contra la nueva v3 con un juez LLM (un gpt-4o
puntuando lo que ningún if ve: si la carta suena a persona o a plantilla), con 3 cartas
el juez dijo que v3 era peor. Con 15, la conclusión se dio la vuelta entera.
N = 3 → v3 parece PEOR en todo (arranque 2,33 vs 3,33) ✗ falso
N = 15 → v3 claramente MEJOR (arranque 4,20 vs 2,80) ✓
# y v1 abre con plantilla 7 de 15 veces; v3, 0 de 15.
Aquellas 3 cartas pillaron por azar un 1 y un 2 que en 15 tiradas ni asoman. La muestra
pequeña no fue solo imprecisa: invirtió el signo de la respuesta. Casi desplegamos la conclusión
contraria a la verdad — la lección más cara del artefacto.
Se acabó el json.loads a ciegas
Pedirle al modelo «devuelve JSON, por favor» no garantiza qué hay dentro: si falta una clave, el fallo
aparece tres funciones más abajo como un None que nadie pidió. La última pieza del artefacto cambia la
súplica por un molde Pydantic que el decodificador está obligado a rellenar.
class Oferta(BaseModel):
puesto: str
empresa: str | None # null explícito, no ausencia muda
imprescindibles: list[str]
...
oferta = cliente.chat.completions.parse(
response_format=Oferta).choices[0].message.parsed # ya validado
Y el cierre bonito: cambiamos toda la fontanería de salida y re-corrimos la báscula. Como los prompts no se tocaron, la calidad tenía que aguantar — y el número lo demostró. Cambiar el cómo no cambió el qué.
Un eval que depende de que te acuerdes de lanzarlo no es una puerta
Todo lo anterior tenía un agujero honesto, y estaba escrito en la propia bitácora: la báscula solo corría cuando yo me acordaba de correrla. El anexo lo cierra. Ahora hay dos vigilantes, y vigilan cosas distintas: la puerta protege el repositorio de mis cambios; el canario vigila que el modelo no se mueva bajo mis pies.
1 · La puerta: puerta-agente.yml
Cada push a main que toque código, casos, listones o el propio workflow levanta un runner, monta el
entorno y corre las 85 llamadas de la báscula. Si un caso baja de su listón, la ejecución sale en rojo y el commit
queda marcado. Tres decisiones que la hacen barata y honesta:
**.py, casos.json, umbral.json, requirements.txt y el YAML. Empujar un HTML no gasta ni un céntimo — y se demostró en vivo: el commit del libro 14 no despertó la puerta.
umbral.json bloquea. Añadir una prueba nueva y olvidarse de calibrarla no puede salir verde por descuido.
gpt-4o se queda fuera a propósito: caro y subjetivo. Su sitio es la ejecución a demanda y el canario, no el camino crítico de cada push.
El hallazgo que rediseñó la fase: el CI no tenía CV. Cinco de los ocho casos leen el currículum, y
esa carpeta está gitignorada a propósito (lleva teléfono y dirección). En el runner sencillamente no existía. Se
resolvió metiendo el CV como secreto — pero eso abrió un segundo filo más interesante: el repo es
público y los logs de Actions también. GitHub enmascara el secreto, no lo derivado de él: una carta
generada lleva dentro la firma del CV. Ahora, en modo --ci, la báscula se calla el detalle de cada
intento y emite solo caso: nota ≥ listón, que es lo único que una máquina necesita. Verificado con una
sonda de coste 0 € que inyectaba un teléfono cebo.
2 · Los tres diales
Calibrar el listón fue lo más fino del anexo, y salió de separar tres cosas que yo trataba como una. Pedir
(lo que el prompt exige: una carta de 130–180 palabras) no es aceptar (lo que el assert da por bueno:
125, perdonando el roce) ni es juzgar (el listón de la puerta: carta_control ≥ 0,90).
El argumento de fondo es lingüístico: bajar la petición hace que el modelo recentre la puntería y aterrice
más abajo, y forzar las últimas cinco palabras mete relleno — la carta cumpliría el contrato y sería peor.
La calibración no fue por intuición sino por datos: agrupando el historial por configuración idéntica se ve que el ruido no está repartido —vive entero en el único caso a temperatura 0,7— y que más repeticiones lo estrechan. Re-puntuando la historia entera: aceptar en 125 bloquea la versión mala con margen; aceptar en 115 la deja pasar. Ese es el precio exacto de la ceguera.
3 · La prueba del rojo
Una puerta que solo has visto pasar es una puerta en la que confías por fe. Que el código diga
sys.exit(1) no demuestra que GitHub pinte la ejecución en rojo. Así que la saboteé a propósito: subí el
listón de un caso a 1,1 — inalcanzable, porque la nota máxima es 1,0 — y lo empujé junto al resto
del trabajo. Una ejecución demuestra el bloqueo y transporta los cambios.
$ python evaluar_prompts.py --ci
of_bug_gcp_aws .............. 1.0 ≥ 0.90 (5/5)
of_bug_cefr_b2 .............. 1.0 ≥ 0.90 (5/5)
of_bug_rango_python_o ....... 0.0 · (0/5) fuera de la puerta
carta_bug_numerologia ....... 1.0 ≥ 0.90 (20/20)
carta_control ............... 1.0 ≥ 0.90 (20/20)
✗ of_control_compuesto_y ...... 1.0 < 1.1 (5/5)
────────────────────────────────────────────────
bugs 0.8 · controles 1.0 · global 0.875
PUERTA CERRADA — exit 1
falla
El caso sacó 5 de 5 perfectos y la puerta lo bloqueó igual. Es exactamente lo que había que demostrar:
quien decide es umbral.json, no el resultado. Revertido en el commit siguiente, la ejecución gemela
salió verde — y las dos quedan una al lado de la otra en el historial público del repo.
$ python evaluar_prompts.py --ci
of_bug_gcp_aws .............. 1.0 ≥ 0.90 (5/5)
of_bug_cefr_b2 .............. 1.0 ≥ 0.90 (5/5)
of_bug_rango_python_o ....... 0.0 · (0/5) fuera de la puerta
carta_bug_numerologia ....... 1.0 ≥ 0.90 (20/20)
carta_control ............... 1.0 ≥ 0.90 (20/20)
✓ of_control_compuesto_y ...... 1.0 ≥ 1.0 (5/5)
────────────────────────────────────────────────
bugs 0.8 · controles 1.0 · global 0.875
PUERTA ABIERTA — exit 0
pasa
Mismo commit de trabajo, mismo modelo, mismas 85 llamadas. Lo único que cambió entre las dos ejecuciones
fue una cifra en el fichero de listones. Un caso queda fuera de la puerta a propósito
(of_bug_rango_python_o, listón 0,0): es una observación abierta, no un estándar alcanzado — si
entrara, el CI nacería en rojo permanente y dejaría de significar nada.
4 · El canario: canario-del-modelo.yml
gpt-4o-mini no es una constante. OpenAI lo actualiza sin avisar, y los prompts se degradan
con el mismo commit. Por eso hay un segundo workflow que corre solo, los lunes a las 06:00 UTC,
sin que nadie haya tocado nada. Su valor está justo ahí: cuando no ha cambiado nada, cualquier movimiento tiene un
único culpable. Aquí sí entra el juez (una carta puede pasar todos los asserts y dejar de sonar a mí;
eso no lo caza una regla, lo caza un lector) e informa sin bloquear.
Semanal y no nocturno, por coste: 85 llamadas más 60 al juez son unos 12 minutos por ejecución; diario, en un repo que se toca dos veces por semana, es pagar siete veces por lo mismo. Y no se dejó en fe: se disparó a mano en vez de esperar al lunes, por el mismo argumento de la prueba del rojo.
Primera línea base de estilo medida en CI · escala 1–5 · run 29849305745
El hallazgo: los tres casos de carta sacan 1,0 clavado en los asserts y el lector les pone
2,9–4,05 de arranque. Cumplen el contrato y empiezan flojas — que es exactamente lo que ninguna regla podía
cazar, y el argumento entero de tener un juez. Si estos números caen un lunes sin haber tocado nada, el culpable
tiene nombre.
5 · Lo que no se buscaba: estabilidad
Cinco ejecuciones consecutivas, en máquinas distintas, dieron bugs 0,8 · controles 1,0 · global 0,875 clavado. El contraste está en la propia bitácora: semanas antes, dos ejecuciones idénticas daban 0,833 y 0,9. La diferencia no es el modelo, son las repeticiones — de 5 a 20. Medir más veces no cambia el modelo: cambia lo que puedes afirmar sobre él.
Un cabo que sigue vivo, y lo digo aquí. El CV es la única entrada de la báscula que está fuera del
control de versiones. Actualizar ese secreto no es un push, así que si un día sincronizo el currículum, las notas
pueden moverse y nada avisa: el canario lo cazaría con hasta siete días de retraso. Los schedule de
GitHub tienen además tres trampas conocidas — solo corren en la rama por defecto, se retrasan en horas punta y
se desactivan solos tras 60 días sin actividad.
Los dos cuadernos del anexo
Cada concepto nuevo del recorrido se escribe como un cuaderno propio, no como un comentario en el código. Estos dos son los del anexo; hay catorce en total.
Los criterios, cumplidos
Escribir buenos prompts lo intenta cualquiera. Lo que distingue es versionarlos, medir cada cambio y haber visto fallar la puerta que los protege. No «tocar y rezar»: tocar y demostrar.