Cómo construí un pipeline editorial con IA que se audita a sí mismo
El README.md del repo dice literalmente: "Juanchi portfolio landing. Automatically synced with your v0.app deployments." Dos líneas. Badge de Vercel. Nada más.
Eso quedó desactualizado en el primer mes. Lo que realmente corre en ese repo en el commit f49b4d1a522a89df7927b5796ef4144ab35ba704 es otra cosa: un sistema editorial que ingesta repositorios reales, construye un brief de código, pasa el contenido por un gate de calidad con score numérico, y rechaza o reescribe automáticamente si no llega al umbral. Todo adentro de Next.js, todo en Railway, sin Vercel en el medio.
La pregunta que me importa no es "¿qué hace el sistema?". Es: ¿cuándo un pipeline editorial automático empieza a tener más criterio que uno mismo? Y más incómoda todavía: ¿cómo sabés que el criterio que codificaste es correcto?
El problema real: contenido que podría firmar cualquiera
Generaba contenido con IA y lo publicaba. Rápido, consistente, prolijo. Y completamente intercambiable con lo que escribe cualquier otro dev que usa el mismo modelo con el mismo prompt. Sin postura, sin cicatriz técnica, sin nada que justificara que lo firmara yo.
El costo no era solo de calidad — era de identidad. Si cada post puede salir de cualquier instancia de Claude sin contexto específico, juanchi.dev no existe como marca: es solo otro agregador de outputs.
La respuesta obvia es un gate. Algo que bloquee contenido genérico antes de que llegue a producción. Pero implementar ese gate es donde se complica, porque terminás construyendo una IA que audita a otra IA, y eso tiene sus propios problemas de calibración.
Mi tesis, antes de arrancar con el código: un gate de calidad solo vale si podés medir cuándo se equivoca. Si no instrumentás el rechazo, el umbral es una apuesta disfrazada de criterio.
Qué revela el scope editorial del repo
Antes de escribir una línea de este post, el pipeline analizó 907 archivos del repo y seleccionó 30 para construir el contexto editorial. No de forma aleatoria: cada archivo tiene un rol asignado — entrypoint, domain_logic, data_model, tests, risk_or_security, operations, configuration, documentation.
Eso es el CodeScopeBrief. La idea es que el contexto que llega al generador no sea un volcado del repo sino una selección deliberada por función arquitectónica. De los 92 archivos de tipo entrypoint disponibles se seleccionaron 4; de los 426 de domain_logic, 4; de los 121 de tests, 3. Presupuesto fijo de tokens, cobertura balanceada.
El detalle que más me importa: tres archivos fueron bloqueados por el scanner de secretos antes de siquiera llegar a la selección editorial. No fue intervención manual — el pipeline detectó claves de Anthropic y un GitHub PAT y los cortó automáticamente. Eso es exactamente lo que querés en un sistema que procesa repos propios, donde un .env commiteado por descuido es más probable de lo que uno admite.
La decisión técnica central: score numérico como contrato
En lib/editorial/editor-service.ts están las tres constantes que son el corazón del sistema:
// lib/editorial/editor-service.ts
export const EDITORIAL_GATE_MIN_SCORE = 81
export const EDITORIAL_REWRITE_MIN_SCORE = 65
export const EDITORIAL_REWRITE_MAX_ROUNDS = 3La lógica: si el score supera 81, pasa. Si está entre 65 y 81, el sistema reintenta la generación hasta 3 veces. Si no llega a 65 ni con 3 intentos, lanza EditorialGateBlockedError y el post no existe.
// lib/editorial/editor-service.ts
export class EditorialGateBlockedError extends Error {
constructor(
public readonly reviewId: string,
public readonly score: number,
) {
super(`Editorial gate blocked content with score ${score} (review ${reviewId})`)
this.name = "EditorialGateBlockedError"
}
}Lo que me parece bien pensado: el error lleva el score en el payload. No es un booleano de rechazo — es evidencia auditable. Podés construir un dashboard de cuántos posts se bloquearon y a qué score promedio fallaron. Eso convierte el gate en algo observable.
Lo que no me cierra: el 81 es un número que yo elegí. No tengo evidencia pública de que ese umbral correlacione con calidad percibida por lectores reales. Es criterio propio codificado como contrato. Funciona mientras el modelo que evalúa y el que genera sean consistentes entre sí — si actualizás uno sin recalibrar el otro, el umbral pierde sentido y no te enterás hasta que algo raro aparece en producción.
Bilingüe por contrato, no por conveniencia
lib/editorial/revision-workflow.ts maneja el ciclo de vida del post después de generado. Incluye una función que calcula readTime a partir del conteo de palabras:
// lib/editorial/revision-workflow.ts
function readTimeFor(content: string) {
const words = content.trim().split(/\s+/).filter(Boolean).length
return Math.max(1, Math.ceil(words / 200))
// 200 palabras/minuto es el estándar que usé; revisable
}Pero lo que más define al sistema es lo que valida isGeneratedContent: no solo que exista es.title y es.slug, sino también en.title, en.slug y en.content. El sistema es bilingüe por contrato — si generás solo en español y no hay traducción al inglés, el contenido no es válido y no se persiste.
Esa fue una decisión que tomé antes de escribir el primer post: o publicás en los dos idiomas o no publicás. El costo es concreto: duplica el gasto de tokens por generación. El beneficio es que los posts pueden cruzar a Dev.to en inglés sin traducción manual, que es donde realmente llegan lectores nuevos.
Crons que dejaron de funcionar y cómo lo resolví
El workflow .github/workflows/awesome-crons.yml tiene un comentario que dice más que cualquier doc:
# Scheduled Awesome jobs run as Railway cron services. The previous GitHub
# schedule called juanchi.dev through Cloudflare and was blocked by managed
# challenge 403 before reaching Next.js.Tenía crons de GitHub Actions que llamaban al endpoint de la app. Cloudflare los bloqueaba con 403 porque el User-Agent de curl sin cabecera especial activa el managed challenge. La solución fue mover los crons a Railway directamente — Railway tiene acceso interno a la app sin pasar por Cloudflare. GitHub Actions quedó solo como trigger manual con workflow_dispatch.
La estructura del dispatcher en app/api/admin/awesome/run/[job]/route.ts es deliberada: un solo endpoint con rate limiting en memoria (RATE_LIMIT_MS = 60_000) y un registro de jobs por nombre:
// app/api/admin/awesome/run/[job]/route.ts
const JOBS: Record<string, JobFn> = {
"repo-sync": (ctx) => runRepoSync(ctx),
"series-publish": (ctx) => runSeriesPublish(ctx, { force: true }),
discovery: (ctx) => runDiscoveryJob(ctx),
}
const lastRun = new Map<string, number>()
const RATE_LIMIT_MS = 60_000El rate limit en memoria tiene un problema conocido: si Railway reinicia el servicio, el mapa se vacía y podés disparar el mismo job dos veces en menos de 60 segundos. Para un blog personal, ese riesgo es aceptable. Para algo con efectos secundarios costosos — facturación, emails, webhooks externos — necesitás persistir el timestamp del último run en base de datos.
El job discovery es el único que se dispara con queueMicrotask porque puede tardar minutos. No podés retener la conexión HTTP abierta mientras eso corre. El resto responde síncronamente antes del maxDuration = 60 que impone la plataforma.
Lo que el modelo de datos revela sobre el producto
La migration de baseline prisma/migrations/20260421000000_baseline_existing_schema/migration.sql tiene enums que cuentan la historia del sistema:
EditorialReviewStatus:ACCEPTED,REWRITTEN,BLOCKED,APPROVED,REJECTED— el ciclo de vida del gate.CuratedVerdict:GEM,WORTH_TRYING,MEH,HYPE,DEAD— curaduría de herramientas.VideoStatus:DRAFT→APPROVED→AUDIO_READY→RENDERING→RENDERED→PUBLISHED→DISCARDED— un pipeline de video completo que todavía no usé en producción.PromptVersionSource:seed,auto_tune,admin,rollback— versionado de prompts con capacidad de rollback.
Ese último enum es el que más me interesa. Significa que el sistema puede cambiar los prompts automáticamente (auto_tune), un admin puede sobreescribir (admin), y si algo sale mal, podés volver al estado anterior (rollback). Es control de versiones aplicado a instrucciones de IA — exactamente lo que necesitás cuando el prompt es parte del producto y no un detalle de implementación que nadie trackea.
El límite honesto del pipeline
El scanner de secretos bloqueó tres archivos — entre ellos lib/repo-ingestion/__tests__/context-builder.test.ts y lib/repo-ingestion/__tests__/file-policy.test.ts. Esos tests son los que verifican que la ingesta funciona correctamente. No los pude analizar.
Hay una parte del pipeline que estoy describiendo sin haber leído su suite de tests. Podría tener casos borde sin cubrir. Lo declaro porque es el comportamiento correcto: cuando el scanner bloquea, lo que corresponde es decirlo, no inventar lo que podría haber adentro.
El otro límite: el umbral de 81 para EDITORIAL_GATE_MIN_SCORE es criterio propio sin validación externa. Es la clase de deuda técnica que no duele hasta que el modelo cambia de versión y el evaluador empieza a puntuar distinto sin que nadie lo note.
La decisión práctica que sigue
Construí un sistema que puede rechazar mi propio contenido. Eso es lo que quería — un criterio que no ceda cuando tengo ganas de publicar algo mediocre o estoy apurado.
Pero el sistema audita contra un score que yo mismo calibré. Si ese score está mal calibrado, estoy bloqueando posts buenos y aprobando posts malos con igual confianza, y no tengo forma de saberlo sin instrumentar el resultado.
La próxima decisión concreta es registrar cada score con su contenido resultante y construir una correlación manual entre score y calidad percibida después de publicar. Sin esa retroalimentación, el umbral de 81 es una apuesta, no un criterio.
¿Cómo medirías que el gate de calidad está calibrado correctamente? La respuesta que me doy ahora mismo no me convence del todo.
Artículos Relacionados
OWASP LLM Top 10 en producción: cómo audité mi pipeline de agentes TypeScript contra los 10 riesgos y qué encontré
Aplicar el OWASP LLM Top 10 como auditoría real es muy distinto a leerlo como lista. Lo corrí contra mi stack de agentes TypeScript con system prompts, MCP tools y Cline — y los hallazgos fueron incómodos.
pnpm workspaces en monorepo: el setup que sobrevivió CI en Railway y los problemas que los docs no anticipan
pnpm workspaces es la mejor opción para monorepos TypeScript en 2026. Pero el path de felicidad de los docs esconde tres trampas que solo aparecen en CI con deployment real: phantom dependencies, hoisting roto en Railway y script filtering que no filtra lo que creés.
OAuth 2.0 Scope Creep: el vector de ataque que el incidente de Vercel dejó al descubierto y cómo auditarlo en tus integraciones
El incidente de Vercel no fue una vulnerabilidad técnica: fue un fallo de principio de mínimo privilegio aplicado a OAuth. Analizá qué es el scope creep, cómo auditarlo en integraciones existentes y qué controles arquitecturales previenen que un tercero acumule permisos que no necesita.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.