Pipeline de Conteúdo Automatizado com IA: do Prompt ao Publish com TypeScript

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema real: conteúdo que não escala sem virar lixo
Gerar conteúdo com IA é trivial. Abrir o ChatGPT, colar um prompt e copiar o resultado leva dois minutos. O problema começa quando você precisa de volume com qualidade mínima: 20 posts por semana, cada um com metadados corretos, validação de frontmatter, checagem de links, revisão humana antes de publicar e deploy automático depois da aprovação.
Sem um pipeline, o processo é manual, frágil e inconsistente. Com um pipeline mal feito, você publica conteúdo genérico que prejudica o domínio. Este post mostra como montar a infraestrutura de automação: a parte de engenharia que transforma "chamar uma API de LLM" em um sistema previsível.
A arquitetura cobre quatro estágios: geração, validação, revisão e publicação. Cada estágio é um módulo independente conectado por fila.
Arquitetura do pipeline em quatro estágios
[Briefing Queue] → [Geração via LLM] → [Validação Programática] → [Revisão Humana] → [Publicação]
↑ |
└── Rejeição com feedback ───────────────┘O fluxo é linear com um loop de rejeição. Se a validação falha, o item volta para a fila de geração com o motivo da falha anexado ao prompt. Se a revisão humana rejeita, o item volta com anotações do revisor. Sem esse loop, o pipeline vira um canhão de conteúdo ruim.
Cada estágio roda como um worker independente. Isso permite escalar a geração (que depende de latência da API) separadamente da validação (que é CPU-bound e rápida).
Modelagem do briefing e da fila
O briefing é o input do pipeline. Ele precisa ser estruturado o suficiente para gerar prompts consistentes, mas flexível para cobrir formatos diferentes.
// src/types/briefing.ts
export interface ContentBriefing {
id: string;
topic: string;
targetKeyword: string;
contentType: "tutorial" | "comparison" | "deep-dive" | "opinion";
targetWordCount: number;
categorySlug: string;
// Contexto extra que o LLM recebe como system prompt
additionalContext?: string;
// Controle de retry
attempt: number;
maxAttempts: number;
previousFeedback?: string;
}
export interface PipelineItem {
briefing: ContentBriefing;
generatedContent?: string;
validationResult?: ValidationResult;
status: "queued" | "generating" | "validating" | "reviewing" | "approved" | "rejected" | "published";
createdAt: Date;
updatedAt: Date;
}Para a fila, BullMQ com Redis resolve. Se o volume é baixo (menos de 50 itens por dia), um array em SQLite com polling já funciona. A escolha depende de quantos workers concorrentes você precisa.
// src/queue/setup.ts
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
// Conexão separada para queue e worker evita bloqueio
// no subscriber do Redis quando a queue publica eventos
const connection = new IORedis({
host: process.env.REDIS_HOST ?? "localhost",
port: Number(process.env.REDIS_PORT ?? 6379),
maxRetriesPerRequest: null,
});
export const generationQueue = new Queue("content-generation", {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 5000 },
removeOnComplete: { count: 100 },
},
});
export const validationQueue = new Queue("content-validation", {
connection,
});
export const publishQueue = new Queue("content-publish", {
connection,
});Se você já trabalha com filas em Node.js, o padrão é familiar. Para quem usa Docker no ambiente de desenvolvimento, a configuração do Redis fica simples com docker-compose.
Geração: chamando o LLM com controle
O worker de geração recebe o briefing, monta o prompt e chama a API. A parte que a maioria dos tutoriais ignora: controle de custo, timeout e tratamento de respostas malformadas.
// src/workers/generation.worker.ts
import { Worker, Job } from "bullmq";
import OpenAI from "openai";
import { ContentBriefing, PipelineItem } from "../types/briefing.js";
import { validationQueue } from "../queue/setup.js";
import { buildSystemPrompt, buildUserPrompt } from "../prompts/builder.js";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// Limite de tokens calculado a partir do wordCount alvo
// GPT-4o produz ~0.75 palavras por token em português
function estimateMaxTokens(targetWords: number): number {
return Math.ceil(targetWords / 0.75) + 200;
}
async function generateContent(job: Job<ContentBriefing>): Promise<void> {
const briefing = job.data;
const systemPrompt = buildSystemPrompt(briefing);
const userPrompt = buildUserPrompt(briefing);
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
max_tokens: estimateMaxTokens(briefing.targetWordCount),
temperature: 0.7,
});
const content = response.choices[0]?.message?.content;
if (!content || content.length < 500) {
throw new Error(`Resposta curta demais: ${content?.length ?? 0} chars`);
}
// Passa para a fila de validação com o conteúdo gerado
await validationQueue.add("validate", {
briefing,
generatedContent: content,
status: "validating",
createdAt: new Date(),
updatedAt: new Date(),
} satisfies PipelineItem);
}
const worker = new Worker("content-generation", generateContent, {
connection: {
host: process.env.REDIS_HOST ?? "localhost",
port: Number(process.env.REDIS_PORT ?? 6379),
},
concurrency: 3, // 3 chamadas paralelas à API
limiter: {
max: 10,
duration: 60_000, // Max 10 jobs por minuto (respeita rate limit)
},
});
worker.on("failed", (job, err) => {
console.error(`Job ${job?.id} falhou: ${err.message}`);
});O módulo de prompt fica separado do worker. Isso permite iterar nos prompts sem tocar na lógica de fila.
// src/prompts/builder.ts
import { ContentBriefing } from "../types/briefing.js";
export function buildSystemPrompt(briefing: ContentBriefing): string {
const baseRules = [
"Você é um redator técnico para um blog de engenharia de software.",
"Escreva em português brasileiro com acentuação correta.",
"Use MDX com frontmatter YAML no topo.",
"Inclua blocos de código com linguagem declarada.",
"Não use emojis no corpo do texto.",
`Categoria: ${briefing.categorySlug}`,
];
// Feedback de tentativa anterior entra como regra adicional
// para que o modelo corrija erros específicos
if (briefing.previousFeedback) {
baseRules.push(
`CORREÇÃO OBRIGATÓRIA: ${briefing.previousFeedback}`
);
}
return baseRules.join("\n");
}
export function buildUserPrompt(briefing: ContentBriefing): string {
return [
`Escreva um post do tipo "${briefing.contentType}" sobre: ${briefing.topic}`,
`Palavra-chave alvo: ${briefing.targetKeyword}`,
`Tamanho alvo: ${briefing.targetWordCount} palavras`,
briefing.additionalContext ?? "",
]
.filter(Boolean)
.join("\n");
}Para quem quer ir além de chamadas HTTP simples e construir ferramentas de linha de comando que integram LLMs, o post sobre CLIs com IA cobre o lado interativo dessa integração.
Validação programática: onde o pipeline ganha valor
A validação é o estágio que diferencia um pipeline sério de um script que cospe texto. Ela verifica estrutura, metadados e qualidade mínima sem depender de humano.
// src/validators/content.validator.ts
import matter from "gray-matter";
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
metrics: {
wordCount: number;
codeBlockCount: number;
headingCount: number;
hasTable: boolean;
};
}
export function validateContent(
raw: string,
targetWordCount: number
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Validação de frontmatter
let frontmatter: Record<string, unknown> = {};
try {
const parsed = matter(raw);
frontmatter = parsed.data;
} catch {
errors.push("Frontmatter YAML inválido ou ausente");
}
const requiredFields = ["title", "excerpt", "slug", "categorySlug"];
for (const field of requiredFields) {
if (!frontmatter[field]) {
errors.push(`Campo obrigatório ausente no frontmatter: ${field}`);
}
}
// Contagem de palavras (exclui código e frontmatter)
const contentBody = raw
.replace(/---[\s\S]*?---/, "")
.replace(/```[\s\S]*?```/g, "")
.trim();
const wordCount = contentBody.split(/\s+/).filter(Boolean).length;
const lowerBound = targetWordCount * 0.7;
const upperBound = targetWordCount * 1.4;
if (wordCount < lowerBound) {
errors.push(`Conteúdo curto: ${wordCount} palavras (mínimo: ${lowerBound})`);
}
if (wordCount > upperBound) {
warnings.push(`Conteúdo longo: ${wordCount} palavras (alvo: ${targetWordCount})`);
}
// Blocos de código
const codeBlocks = raw.match(/```\w+/g) ?? [];
const codeBlockCount = codeBlocks.length;
if (codeBlockCount < 3) {
warnings.push(`Poucos blocos de código: ${codeBlockCount}`);
}
// Blocos sem linguagem declarada
const untyped = raw.match(/```\n/g) ?? [];
if (untyped.length > 0) {
errors.push(`${untyped.length} bloco(s) de código sem linguagem declarada`);
}
// Headings
const headings = raw.match(/^#{2,3}\s+.+/gm) ?? [];
const headingCount = headings.length;
if (headingCount < 3) {
errors.push(`Poucas seções: ${headingCount} headings (mínimo: 3)`);
}
const hasTable = raw.includes("| --- ") || raw.includes("|---");
return {
valid: errors.length === 0,
errors,
warnings,
metrics: { wordCount, codeBlockCount, headingCount, hasTable },
};
}O worker de validação conecta o resultado ao fluxo de rejeição ou aprovação:
// src/workers/validation.worker.ts
import { Worker, Job } from "bullmq";
import { PipelineItem } from "../types/briefing.js";
import { validateContent } from "../validators/content.validator.js";
import { generationQueue, publishQueue } from "../queue/setup.js";
async function validateJob(job: Job<PipelineItem>): Promise<void> {
const item = job.data;
const result = validateContent(
item.generatedContent!,
item.briefing.targetWordCount
);
if (!result.valid) {
// Volta para geração com feedback dos erros
if (item.briefing.attempt < item.briefing.maxAttempts) {
await generationQueue.add("regenerate", {
...item.briefing,
attempt: item.briefing.attempt + 1,
previousFeedback: result.errors.join("; "),
});
return;
}
// Esgotou tentativas: marca como rejeitado para revisão manual
console.error(`Item ${item.briefing.id} esgotou tentativas: ${result.errors.join(", ")}`);
return;
}
// Validação passou: envia para revisão/publicação
await publishQueue.add("review", {
...item,
validationResult: result,
status: "reviewing",
updatedAt: new Date(),
});
}
const worker = new Worker("content-validation", validateJob, {
connection: {
host: process.env.REDIS_HOST ?? "localhost",
port: Number(process.env.REDIS_PORT ?? 6379),
},
});O que NÃO fazer
Erro 1: publicar sem validação de frontmatter
// ERRADO: confia que o LLM gerou frontmatter correto
async function publishDirectly(content: string) {
// Escreve direto no filesystem sem checar nada
await fs.writeFile(`posts/${Date.now()}.mdx`, content);
}O LLM frequentemente gera frontmatter com campos faltando, slugs com espaços ou categorias que não existem no seu sistema. O resultado: build quebrado, páginas 404, SEO comprometido.
// CORRETO: valida antes de gravar
async function publishWithValidation(content: string) {
const result = validateContent(content, 2000);
if (!result.valid) {
throw new Error(`Validação falhou: ${result.errors.join(", ")}`);
}
const { data } = matter(content);
const safeSlug = String(data.slug)
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-");
await fs.writeFile(`posts/${safeSlug}.mdx`, content);
}Erro 2: sem limite de retry
// ERRADO: loop infinito de regeneração
if (!result.valid) {
await generationQueue.add("regenerate", briefing);
// Se o prompt é fundamentalmente ruim, isso roda para sempre
// e gasta créditos da API indefinidamente
}Defina maxAttempts no briefing (3 é um bom padrão). Depois de 3 tentativas, o item precisa de intervenção humana.
Erro 3: temperatura alta sem pós-processamento
Temperatura acima de 0.9 gera texto criativo, mas também gera alucinações em nomes de funções, flags de CLI inexistentes e imports de pacotes que não existem. Se o conteúdo é técnico, mantenha temperatura entre 0.5 e 0.7 e valide os blocos de código separadamente.
Comparação de abordagens para a fila
| Critério | BullMQ + Redis | SQLite + polling | Fila em memória (array) |
|---|---|---|---|
| Persistência entre restarts | Sim | Sim | Não |
| Concorrência entre workers | Nativa | Manual (row locking) | Não aplicável |
| Complexidade de setup | Média (precisa de Redis) | Baixa | Mínima |
| Rate limiting nativo | Sim | Não | Não |
| Ideal para | 50+ itens/dia, múltiplos workers | Até 50 itens/dia, single process | Prototipação, testes |
| Observabilidade | Dashboard BullMQ, eventos | Query SQL | console.log |
Para ambientes de produção com deploy containerizado, BullMQ com Redis é a escolha padrão. Para um blog pessoal que gera 5 posts por semana, SQLite com um cron job resolve sem infraestrutura adicional.
Publicação: do filesystem ao deploy
O worker de publicação grava o arquivo MDX no repositório e dispara o build. Se o blog roda em Next.js com SSR ou SSG, o deploy é um git push que aciona o CI.
// src/workers/publish.worker.ts
import { Worker, Job } from "bullmq";
import { execSync } from "node:child_process";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import matter from "gray-matter";
import { PipelineItem } from "../types/briefing.js";
const CONTENT_DIR = process.env.CONTENT_DIR ?? "./content/posts";
async function publishJob(job: Job<PipelineItem>): Promise<void> {
const { generatedContent, briefing } = job.data;
if (!generatedContent) throw new Error("Conteúdo vazio");
const { data } = matter(generatedContent);
const slug = String(data.slug).replace(/[^a-z0-9-]/g, "-");
const categoryDir = join(CONTENT_DIR, briefing.categorySlug);
mkdirSync(categoryDir, { recursive: true });
const filePath = join(categoryDir, `${slug}.mdx`);
writeFileSync(filePath, generatedContent, "utf-8");
// Commit e push automático
// Em produção, use uma lib como simple-git em vez de execSync
execSync(`git add "${filePath}"`, { cwd: CONTENT_DIR });
execSync(
`git commit -m "feat(content): add ${slug}"`,
{ cwd: CONTENT_DIR }
);
execSync("git push origin main", { cwd: CONTENT_DIR });
}
const worker = new Worker("content-publish", publishJob, {
connection: {
host: process.env.REDIS_HOST ?? "localhost",
port: Number(process.env.REDIS_PORT ?? 6379),
},
concurrency: 1, // Serializa commits para evitar conflito de merge
});A concorrência 1 no worker de publicação é intencional. Commits paralelos no mesmo branch geram conflitos de merge que quebram o pipeline silenciosamente.
Se o blog usa SSR com Next.js, o push aciona o rebuild automático. Para SSG, o mesmo push funciona: a diferença está no tempo de build, não no pipeline.
Monitoramento: saber quando o pipeline quebra
Um pipeline sem observabilidade é uma caixa preta que publica lixo. O mínimo viável: um endpoint que retorna o estado das filas.
// src/api/status.ts
import { generationQueue, validationQueue, publishQueue } from "../queue/setup.js";
export async function getPipelineStatus() {
const [genCounts, valCounts, pubCounts] = await Promise.all([
generationQueue.getJobCounts(),
validationQueue.getJobCounts(),
publishQueue.getJobCounts(),
]);
return {
generation: genCounts,
validation: valCounts,
publish: pubCounts,
healthy:
genCounts.failed === 0 &&
valCounts.failed === 0 &&
pubCounts.failed === 0,
};
}Acople isso a um sistema de notificações em tempo real ou a um webhook no Slack. O ponto: se failed > 0 em qualquer fila, alguém precisa olhar.
Para proteger o endpoint de status e qualquer API administrativa do pipeline, aplique os padrões de autenticação e rate limiting que você já usaria em qualquer API interna.
O humano no loop não é opcional
Existe uma tentação de automatizar tudo: gerar, validar, publicar, sem nenhum par de olhos humanos. Isso é um erro quando o conteúdo carrega a marca do seu blog ou empresa.
A validação programática pega problemas estruturais: frontmatter quebrado, código sem linguagem declarada, contagem de palavras fora do range. Ela não pega alucinações factuais, tom inadequado ou exemplos de código que compilam mas fazem a coisa errada.
O estágio de revisão humana pode ser tão simples quanto um endpoint que lista itens com status reviewing e dois botões: aprovar ou rejeitar com comentário. O comentário de rejeição vira o previousFeedback do próximo ciclo de geração.
Se você precisa de feature flags para controlar quais tipos de conteúdo passam por revisão humana e quais são publicados automaticamente (newsletters internas vs. posts públicos, por exemplo), a implementação própria sem vendor lock-in é mais adequada do que contratar um SaaS para isso.
Posição técnica: automação de conteúdo é infraestrutura, não produto
O valor de um pipeline de conteúdo com IA não está na geração. A geração é uma chamada de API. O valor está na validação, no loop de feedback, na observabilidade e na decisão humana final.
Tratar automação de conteúdo como "instalar um plugin que escreve sozinho" produz volume sem qualidade. Tratar como infraestrutura de engenharia, com filas, validação, retry e monitoramento, produz um sistema previsível onde o LLM é um componente substituível. Se amanhã a API da OpenAI ficar cara demais, você troca o modelo no worker de geração e o resto do pipeline continua funcionando.
A parte mais difícil não é técnica: é definir o que "qualidade mínima" significa para o seu contexto e codificar isso em validadores. Comece com regras simples (frontmatter, contagem de palavras, blocos de código). Adicione regras semânticas conforme descobre padrões de falha. O pipeline evolui com o que você aprende sobre as limitações do modelo que está usando.
FAQ
Qual o custo médio por post gerado com GPT-4o?
Um post de 2000 palavras em português consome aproximadamente 3000 tokens de input (prompt + sistema) e 3000 tokens de output. Com o preço do GPT-4o em maio de 2025 (US$ 2.50/1M input, US$ 10.00/1M output), cada geração custa cerca de US$ 0.04. Com 3 tentativas no pior caso, o custo máximo por post aprovado fica em US$ 0.12. O custo real está no tempo de revisão humana, não na API.
Posso usar modelos open source em vez da API da OpenAI?
Sim. O worker de geração é o único ponto de acoplamento com o provedor de LLM. Substitua a chamada openai.chat.completions.create por uma chamada a Ollama (local), Together AI, ou qualquer API compatível com o formato OpenAI. A qualidade do output muda, mas o pipeline não precisa de nenhuma alteração nos outros estágios.
Como evitar que o LLM gere código que não compila?
A validação programática pode executar um tsc --noEmit nos blocos TypeScript extraídos do post. Isso adiciona complexidade (precisa de um tsconfig temporário e dos tipos das bibliotecas referenciadas), mas é a forma confiável de garantir que o código compila. Para posts que usam múltiplas linguagens, ferramentas como Vitest podem rodar testes básicos nos exemplos.
Preciso de Kubernetes para rodar isso?
Não. O pipeline inteiro roda em um único processo Node.js com BullMQ apontando para um Redis local. Para volumes abaixo de 100 posts por dia, um VPS de US$ 10/mês com Docker Compose é suficiente. Kubernetes faz sentido quando você tem múltiplos pipelines, precisa de autoscaling nos workers ou já tem a infraestrutura pronta.
Como integrar revisão humana sem construir uma UI completa?
A forma mais rápida: um script CLI que lista itens pendentes, abre o conteúdo no editor padrão do sistema e aceita approve ou reject --reason "motivo" como comandos. Isso elimina a necessidade de frontend dedicado e mantém o fluxo no terminal, onde a maioria dos devs já está.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

Como configurar CI/CD para Next.js na Vercel com GitHub Actions

Anatomia do Vivo de Codigo: Como Construi um Blog com Stack de Startup

CLI com IA: Construindo Ferramentas de Linha de Comando em Python e JavaScript que Usam LLMs de Verdade
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.