O Discourse AI está disponível com relatórios de IA há dois anos e meio!
Este recurso extremamente poderoso permite comprimir grandes quantidades de informações do fórum em um relatório mais gerenciável. Você pode ver isso em ação no Meta:
Ao longo dos anos, os modelos tornaram-se mais inteligentes e a qualidade dos relatórios melhorou. Todos nós vimos a qualidade dos relatórios melhorar sempre que um laboratório de ponta lançava um novo modelo. Nenhuma alteração foi necessária do nosso lado.
Os fluxos de trabalho do Discourse agora permitem uma nova maneira de melhorar a qualidade dos relatórios sem precisar atualizar os modelos de linguagem.
O pipeline de relatórios original do Discourse AI era estático:
- Coletar contexto
- Executar chamada de LLM no contexto
- Criar publicação
Este processo simples funciona surpreendentemente bem; os modelos de ponta são espetacularmente bons em comprimir grandes quantidades de dados.
No entanto, mesmo os modelos mais inteligentes por aí podem cometer erros às vezes.
Os fluxos de trabalho nos permitem encadear vários agentes de IA para obter melhores resultados.
- Nosso agente Report Runner é responsável por criar um rascunho do relatório.
- Nosso verificador de fatos analisa o rascunho do relatório e corrige erros usando um loop agêntico
Ampliando os nós que fazem um sistema como este funcionar
O nó de agendamento inicia o fluxo de trabalho; é importante observar que ele está em UTC. O suporte a fuso horário será adicionado no futuro.
O nó list posts permite que selecionemos um filtro de publicações; os filtros de publicações são semelhantes aos filtros de tópicos, exceto que operam no nível de publicação, permitindo obter vários resultados por tópico.
Incluir o conteúdo bruto da publicação é crítico para nossos relatórios (e limitar a quantidade de informações retornadas por publicação) para manter o contexto gerenciável.
A formatação do relatório para que o agente possa entendê-lo é feita usando um bloco JavaScript personalizado.
const items = $input.all();
function asArray(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function formatDate(value) {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
return date.toISOString().slice(0, 16).replace("T", " ");
}
function truncate(text, maxLength) {
if (!text) {
return "";
}
const value = String(text);
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength).trimEnd()}...`;
}
function postFromItem(item) {
const json = item.json || {};
return json.post || json;
}
const posts = items
.map((item) => postFromItem(item))
.filter((post) => post && post.id && post.topic_id);
const summary = {
returned_posts: posts.length,
returned_topics: new Set(posts.map((post) => post.topic_id)).size,
total_likes: posts.reduce((sum, post) => sum + Number(post.like_count || 0), 0),
top_users: [],
};
const users = new Map();
for (const post of posts) {
const username = post.username || "unknown";
const existing = users.get(username) || {
username,
posts: 0,
likes: 0,
};
existing.posts += 1;
existing.likes += Number(post.like_count || 0);
users.set(username, existing);
}
summary.top_users = Array.from(users.values())
.sort((left, right) => {
if (right.likes !== left.likes) {
return right.likes - left.likes;
}
if (right.posts !== left.posts) {
return right.posts - left.posts;
}
return left.username.localeCompare(right.username);
})
.slice(0, 10);
const topicMap = new Map();
for (const post of posts) {
const topicId = post.topic_id;
if (!topicMap.has(topicId)) {
topicMap.set(topicId, {
id: topicId,
title: post.topic_title || `Tópico ${topicId}`,
slug: post.topic_slug,
category_id: post.category_id,
category_name: post.category_name,
tags: asArray(post.tags),
post_likes: 0,
posts: [],
});
}
const topic = topicMap.get(topicId);
topic.post_likes += Number(post.like_count || 0);
const tags = asArray(post.tags);
for (const tag of tags) {
if (!topic.tags.includes(tag)) {
topic.tags.push(tag);
}
}
if ((post.raw || '') !== '') {
topic.posts.push({
id: post.id,
topic_id: post.topic_id,
post_number: post.post_number,
reply_to_post_number: post.reply_to_post_number,
post_url: post.post_url,
username: post.username,
user_id: post.user_id,
created_at: post.created_at,
created_at_formatted: formatDate(post.created_at),
updated_at: post.updated_at,
excerpt: post.excerpt,
raw: post.raw,
cooked: post.cooked,
like_count: Number(post.like_count || 0),
reply_count: Number(post.reply_count || 0),
score: Number(post.score || 0),
});
}
}
const topics = Array.from(topicMap.values())
.map((topic) => {
topic.posts.sort((left, right) => {
return Number(left.post_number || 0) - Number(right.post_number || 0);
});
return topic;
})
.sort((left, right) => {
if (right.post_likes !== left.post_likes) {
return right.post_likes - left.post_likes;
}
return String(left.title).localeCompare(String(right.title));
});
let context = "";
context += "## Resumo\n\n";
context += `Publicações retornadas: ${summary.returned_posts}\n`;
context += `Tópicos retornados: ${summary.returned_topics}\n`;
context += `Total de curtidas nas publicações retornadas: ${summary.total_likes}\n\n`;
context += "Principais usuários nas publicações retornadas:\n";
for (const user of summary.top_users) {
context += `@${user.username} (${user.likes} curtidas, ${user.posts} publicações)\n`;
}
context += "\n## Tópicos\n";
for (const topic of topics) {
context += `\n### ${topic.title}\n\n`;
context += `topic_id: ${topic.id}\n`;
if (topic.category_name) {
context += `categoria: ${topic.category_name}\n`;
}
if (topic.tags.length > 0) {
context += `tags: ${topic.tags.map((tag) => `#${tag}`).join(", ")}\n`;
}
context += `curtidas_exemplo: ${topic.post_likes}\n`;
let lastPostNumber = 0;
for (const post of topic.posts) {
if (post.post_number && lastPostNumber && post.post_number > lastPostNumber + 1) {
context += "\n...\n";
}
context += "\n";
context += `post_id: ${post.id}\n`;
context += `post_number: ${post.post_number}\n`;
if (post.post_url) {
context += `url: ${post.post_url}\n`;
}
if (post.created_at_formatted) {
context += `${post.created_at_formatted}\n`;
}
if (post.username) {
context += `usuário: @${post.username}\n`;
}
context += `curtidas: ${post.like_count}\n`;
const body = post.raw || post.excerpt || "";
context += `${truncate(body, 4000)}\n`;
lastPostNumber = Number(post.post_number || lastPostNumber);
}
}
return [
{
json: {
summary,
topics,
context,
},
},
];
Notavelmente, ao criar relatórios, você tem controle total sobre a forma dos dados passados ao agente e o Prompt do Sistema que ele usa.
O nó de mesclagem permite que peguemos 2 relatórios e os combinemos em um único relatório.
Nossa atualização semanal interna é composta por atualizações da equipe e atividade geral em nossa comunidade interna. Ao mesclar ambas em um único fluxo, podemos criar um relatório coeso a partir de 2 fontes diferentes.
Finalmente, o trabalho pesado de corrigir o relatório é deixado para um agente verificador de fatos:
Notavelmente, uma única ferramenta de leitura de publicação com um loop agêntico é capaz de realizar o trabalho principal de percorrer cada fato do relatório e garantir que não seja alucinado.
Esta estratégia nos permite obter melhor inteligência por menos dinheiro. Nosso relatório semanal costumava ser gerado com Claude Opus 4.8, que é um ótimo modelo, mais capaz que o DeepSeek v4 Flash. No entanto, ao definir corretamente o pipeline e o loop de verificação, somos capazes de obter melhores resultados por menos dinheiro.
Espero que você ache este tutorial útil; me avise se tiver alguma dúvida.













