Pipeline editorial MNCV · Engenharia de aplicações com IA · 2026
Quando a busca falha, o texto deve esperar
Recuperação de fontes, estados da fila e revisão humana no pipeline editorial do Meu Novo Carro Velho
- Minha contribuição
- Curadoria das pautas, preparação da base, filtros e decisões sobre geração e revisão.
- Entrega
- Automação do blog com revisão humana; extensão social testada manualmente.
- Operação
- 2026 · desativada em 04/07/26
Projeto preservado como portfólio
O Meu Novo Carro Velho (MNCV) era um produto pra ajudar na compra de carros usados. Reunia microaulas, checklist e comparação de carros. O blog tratava de dúvidas dessa compra, como avaliar sinais de batida, conferir documentos e evitar golpes.
Pra produzir os artigos, construí uma automação que partia de pautas escolhidas por mim, buscava material de apoio e gerava um texto para revisão. Eu podia aprovar, pedir ajustes ou rejeitar. A publicação dependia dessa decisão.
É esse caminho que chamo aqui de pipeline editorial: uma sequência de etapas que leva a pauta até o artigo publicado. O projeto juntava banco de dados, busca em documentos e modelos de IA. Entre essas partes, era preciso guardar o estado de cada pauta e decidir o que fazer quando alguma etapa falhasse.
A automação do blog rodou em 2026 e foi desligada em 04/07/26. Hoje, o MNCV serve como portfólio. Este relato mostra como o fluxo foi construído e o que mudou depois dos testes e das falhas em operação.
O que cada tecnologia fazia
O n8n coordenava a automação. Ele encadeia etapas, chamadas de nós, que podem buscar dados, executar código, chamar outro serviço ou esperar uma resposta humana. O fluxo rodava numa VPS, um servidor virtual, com Docker.
| Tecnologia | Papel no projeto |
|---|---|
| n8n | Disparar o fluxo no horário agendado, passar os dados entre etapas e pausar para minha revisão |
| Supabase / PostgreSQL | Guardar as pautas, seus estados, os artigos e a fila de tarefas para as redes sociais |
**Edge Function admin-blog** | Receber as chamadas da automação para reservar pautas, atualizar estados e gravar conteúdo no banco |
| Gemini File Search | Buscar trechos numa base de documentos de vistoria, filtrados pelo assunto da pauta |
| Claude Sonnet | Usar a pauta e os trechos recebidos para gerar a estrutura do artigo, o rascunho e os metadados de publicação |
| Gmail e formulário do n8n | Entregar o texto para revisão e receber minha decisão de aprovar, refazer ou rejeitar |
| SQLite do n8n | Guardar as execuções do fluxo, inclusive as que estavam pausadas |
| Python e JavaScript | Python gerava a definição dos fluxos e os scripts de operação; JavaScript tratava os dados dentro dos nós |
Os serviços se comunicavam por APIs, interfaces que permitem a um programa pedir dados ou executar ações em outro. A função admin-blog fazia essa ponte com o banco do produto.
O banco do Supabase e o SQLite tinham funções diferentes. No primeiro, ficava a pauta e sua situação no processo editorial. No segundo, o registro da execução da automação. Essa separação ficou importante quando uma pauta apareceu como “em andamento”, mas o fluxo já tinha parado.
A base de vistoria também tinha outro papel. Ela reunia documentos de apoio à escrita, enquanto a fila guardava o que deveria virar artigo. O File Search organizava esses documentos para a busca e recuperava trechos. O Sonnet recebia os trechos para escrever. Essa combinação de busca em uma base com geração de texto é chamada de RAG.
O caminho da informação
Eu escolhia as pautas. Cada uma tinha tema, palavra-chave, categoria e um briefing com as orientações de escrita. Depois, entraram campos para termos de busca e perguntas frequentes. No horário agendado, o n8n pegava a pauta pendente mais antiga.
Pauta no Supabase
→ consulta à base de vistoria no Gemini File Search
→ extração dos trechos recuperados
→ estrutura do artigo → rascunho → título e metadados
→ e-mail + formulário de revisão
aprovar → gravar e publicar → enfileirar variações sociais
refazer → incorporar feedback e gerar outro rascunho
rejeitar → encerrar a pauta sem publicarEntre a busca e a escrita, um nó de código extraía os trechos encontrados na base. Eles vinham no campo groundingChunks[].retrievedContext.text da resposta da busca. O código tirava repetições exatas e montava o campo fontes.
Esses trechos entravam nos prompts, as instruções enviadas ao modelo para gerar a estrutura do artigo e o rascunho.
As fontes tinham um teto de 6.000 caracteres. O corte controlava o tamanho da entrada, mas podia parar no meio de um trecho. E o nome rag_ok prometia mais do que a variável media: ela só dizia se tinha vindo algum texto. Relevância, correção dos fatos e fidelidade às fontes continuavam por conferir.
Minha parte estava em preparar a base, definir os filtros e ligar a busca à escrita e à revisão. A indexação e a recuperação dos documentos ficavam com o serviço gerenciado do File Search.
A consulta estava pedindo a coisa errada
Uma pauta de segurança voltou sem fontes. Só que havia material sobre o assunto na base. A consulta misturava tema e briefing, então a busca recebia a pergunta técnica junto de instruções como “tom de amiga que já passou por isso”.
O modelo entrou no personagem. Respondeu naquele tom e não trouxe os trechos da base. A busca tinha recebido instruções de escrita.
A correção foi separar o que cada etapa precisava receber. O briefing ficou com o modelo que escrevia. Pra busca, foram palavra-chave, tema e termos relacionados ao assunto, guardados no campo termos_semanticos:
const query = [j.palavra_chave_alvo, j.tema, j.termos_semanticos]
.map(x => (x || '').toString().trim())
.filter(Boolean)
.join(' | ');A busca usava a categoria da pauta para filtrar os documentos pela classificação guardada nos metadados. No teste de 05/06/26, a mesma pauta voltou com zero trechos na consulta antiga e cinco na nova. O teste mostrou que a mudança resolveu aquele caso. A fila inteira não passou por essa comparação.
Já uma pauta sobre combustível voltava vazia por outro motivo. Na categoria consultada, havia documentos sobre preço e critérios de escolha, mas nenhum sobre aquele subtema. Podia tentar de novo: o material continuaria faltando.
Vazio e falha pedem decisões diferentes
Em 29/06/26, chegou pra eu revisar um texto sobre partida e sistema elétrico de carro. Tinha sido escrito sem os trechos da base de vistoria. A busca estourou o tempo de espera. O fluxo seguiu só com o briefing, e o e-mail avisou que o texto tinha saído sem a base.
Estava funcionando como previsto. Só que isso vinha se repetindo. Decidi que, se a busca estivesse fora do ar, o texto tinha que esperar.
A primeira versão continuava escrevendo nos dois casos, busca vazia ou erro. O e-mail avisava que o rascunho tinha saído sem a base, e eu decidia o que fazer com ele.
A fila andava, mas a falha da busca virava trabalho de revisão pra mim. Em junho, o serviço devolveu erros 503, que indicam indisponibilidade, e deixou chamadas sem resposta. O tempo máximo de espera caiu de 300 para 45 segundos. O nó HTTP, responsável pela chamada ao serviço, passou a fazer até cinco tentativas curtas. Se o serviço demorasse mais pra voltar, o texto ainda saía sem fontes.
Em 29/06, o fluxo ganhou uma decisão antes de extrair as fontes:
| Retorno da busca | Caminho implementado |
|---|---|
| Com trechos recuperados | Extrair as fontes e seguir para a geração |
| Sem erro informado, mas sem trechos | Seguir com o briefing e avisar no e-mail de revisão |
| Com erro informado pelo nó HTTP | Suspender a geração e esperar três horas para tentar de novo |
| Erro persistente após três rodadas | Devolver a pauta para pendente e avisar que o post foi adiado |
As três rodadas espaçadas vinham além das tentativas curtas do nó HTTP. Pra repetir a busca, o fluxo precisava voltar ao nó que tinha carregado a pauta e pegar os dados de novo.
A cada chamada à API, o nó HTTP substituía os dados de entrada pela resposta recebida. Se o fluxo tentasse montar a próxima consulta a partir dessa resposta de erro, os campos da pauta já não estariam ali.
O código ainda faz uma distinção grosseira. Olha se existe $json.error, sem separar erro passageiro de erro que vai continuar na próxima tentativa. E voltar sem trechos pede investigação. Pode faltar material, mas pode ser a consulta malfeita, como aconteceu com o briefing.
O registro de 29/06 confirma que a mudança foi instalada no ambiente de operação. O teste completo das esperas de três horas ficou pendente. O código está lá, mas ainda não achei nos registros a prova de uma execução que tenha esperado e se recuperado por esse caminho.
Uma pauta em andamento pode estar esperando alguém
O estado da pauta ficava no Supabase. A execução, inclusive a pausa para revisão, ficava no n8n.
| Situação | Estado da pauta |
|---|---|
| Disponível para a próxima execução | pendente |
| Reservada pelo fluxo, inclusive durante a revisão | em_andamento |
| Texto gravado após aprovação | aprovado |
| Recusada na revisão | rejeitado |
| Falha encaminhada para tratamento ou limite de pedidos para refazer atingido | erro |
Pra pegar uma pauta, o fluxo primeiro lia a fila e depois tentava mudar a linha para em_andamento. A atualização só valia se ela ainda estivesse pendente. Assim, duas execuções não conseguiam pegar a mesma linha. Era uma fila pequena, sem distribuição de trabalho entre vários processos.
No botão Refazer, a pauta continuava em andamento e meus comentários voltavam pra geração do rascunho. O fluxo reaproveitava a estrutura e as fontes, então pedir outro ângulo para o artigo não cabia nesse botão. O teste percorreu uma volta: o texto incorporou os comentários e o contador subiu. O teste não chegou ao limite de pedidos para refazer previsto no código.
Em 10/06, a autorização de acesso ao Gmail falhou e o e-mail não saiu. Essa autorização usava OAuth. A execução parou antes da pausa de revisão, mas a pauta ficou em em_andamento. Olhando só o banco, dava pra achar que eu estava lendo o texto. O processamento tinha morrido antes de me avisar.
Um script fora do n8n varria as pautas presas. Antes de devolvê-las à fila, consultava as execuções no SQLite. Se encontrasse uma execução em waiting, ou seja, pausada, deixava os itens daquele fluxo quietos. Também respeitava uma folga de 15 minutos para execuções recém-iniciadas.
A proteção olhava o fluxo inteiro, sem ligar cada pauta à sua execução. Por isso, um texto esperando minha revisão podia segurar a recuperação de outra pauta que estivesse presa de verdade. Era o limite dessa solução na operação pequena.
Atualizar o fluxo podia quebrar uma revisão
O link do e-mail abria um formulário que retomava a execução pausada. Só que o script que gerava o fluxo criava novos identificadores de webhook toda vez que rodava. Esses identificadores faziam parte dos endereços de retomada. Atualizar o fluxo com uma revisão pendente podia matar o link que já estava na minha caixa de entrada.
O script de deploy, usado para instalar as atualizações, passou a consultar o SQLite. Se encontrasse alguma execução em waiting, recusava a atualização. A listagem consultada pela API REST do n8n não mostrava essas execuções naquele ambiente. Pela API, parecia livre. No banco, havia revisão esperando.
A checagem passou no teste ao vivo registrado. O custo era depender do banco interno do n8n e de acesso a ele. A consulta e a atualização continuam separadas: nada impede que uma execução entre em espera no intervalo entre as duas.
Ao processar em lote, a resposta perdeu o dono
Depois da aprovação, o blog criava uma tarefa na fila, chamada de job, para gerar versões de Instagram e Pinterest. Essa parte rodou em testes manuais e ficou desligada como rotina. A postagem automática nas redes ficou pra depois.
Com um item, o fluxo funcionava. Quando passou a receber um lote, perdeu a ligação entre a resposta e o job. De novo, a chamada HTTP tinha substituído os campos de entrada pela resposta do modelo.
A correção recuperava os identificadores da tarefa (job_id) e do artigo (blog_post_id) no nó anterior, pela posição do item. Essa associação funciona enquanto a chamada preserva a ordem e a quantidade dos itens.
Item inválido ganhou uma saída própria, pra um erro de leitura não derrubar o lote inteiro. No teste de 02/06/26, três jobs geraram seis variações gravadas e um único e-mail de resumo. Esse foi o alcance do teste; a rotina nas redes não chegou a operar.
O que foi verificado
Os testes de junho confirmaram que as fontes chegavam aos prompts e compararam textos gerados com e sem a busca. Também testaram os caminhos Aprovar, Refazer e Rejeitar. Busca vazia e erro provocado de propósito passaram pela regra antiga, que deixava escrever sem fontes. O bloqueio e as esperas vieram depois e precisam de outra validação.
Dos dez posts do lote de 05/06, nove receberam trechos da base. Isso conta quantos tiveram fontes à disposição. Pra dizer quantos estavam corretos ou se houve invenção, seria preciso conferir as afirmações dos artigos.
A partir de 05/06, clicar em Aprovar publicava o texto. Antes, os testes gravavam rascunhos. A conferência ficava comigo; o fluxo não checava automaticamente cada afirmação contra a fonte.
Onde olhar
Pra acompanhar essas decisões no código, o ponto de partida é build_workflow.py. Ele define o fluxo do blog, incluindo a consulta recortada acima. O desenho no início deste texto é um esquema simplificado.
As outras peças são:
deploy_blog.py: confere as execuções pausadas antes de atualizar o fluxo;varredor_orfaos.py: recupera itens presos;build_social_workflow.py: gera as variações para as redes sociais.
O projeto é pequeno. Mesmo assim, o campo em_andamento sozinho não dizia se um texto esperava revisão ou se o processamento tinha morrido. Era preciso consultar as execuções. E um texto bem escrito podia ter chegado sem a base que deveria sustentá-lo. Foi nesses pontos que o fluxo precisou mudar.
Feito com Claude Code: eu fiz a curadoria das pautas, preparei a base e decidi os filtros, a revisão e o bloqueio após falha. O código dos fluxos e o rascunho deste texto foram feitos com a ferramenta. Revisei o texto e assino.