Discourse AI est livré avec des rapports IA depuis deux ans et demi !
Cette fonctionnalité extrêmement puissante vous permet de compresser de grandes quantités d’informations sur le forum en un rapport plus gérable. Vous pouvez voir cela en action sur Meta :
Au fil des ans, les modèles sont devenus plus intelligents et la qualité des rapports s’est améliorée. Nous avons tous constaté l’amélioration de la qualité des rapports à chaque fois qu’un laboratoire de pointe a publié un nouveau modèle. Aucun changement n’était nécessaire de notre côté.
Les workflows Discourse permettent désormais une nouvelle manière d’améliorer la qualité des rapports sans avoir besoin de mettre à niveau les modèles de langage.
Le pipeline de rapport Discourse AI original était statique :
- Rassembler le contexte
- Exécuter un appel LLM sur le contexte
- Créer le message
Ce processus simple fonctionne de manière surprenante bien, les modèles de pointe sont spectaculaires pour compresser de grandes quantités de données.
Cependant, même les modèles les plus intelligents peuvent parfois faire des erreurs.
Les workflows nous permettent de chaîner plusieurs agents IA pour obtenir de meilleurs résultats.
- Notre agent “Exécutant de rapport” est responsable de la création d’un brouillon de rapport.
- Notre vérificateur de faits scanne le brouillon de rapport et corrige les erreurs à l’aide d’une boucle agentique
Zoom sur les nœuds qui font fonctionner un système comme celui-ci
Le nœud de planification déclenche le workflow, notez bien qu’il est en UTC. La prise en charge des fuseaux horaires sera ajoutée dans le futur.
Le nœud “liste des messages” nous permet de sélectionner un filtre de messages, les filtres de messages sont similaires aux filtres de sujets sauf qu’ils opèrent au niveau du message, donc vous pouvez obtenir plusieurs résultats par sujet.
Inclure le contenu brut du message est critique pour nos rapports (et limiter la quantité d’informations retournées par message) pour garder le contexte gérable
Le formatage du rapport pour que l’agent puisse le comprendre est effectué à l’aide d’un bloc JavaScript personnalisé
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 || `Topic ${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 += "## Résumé\n\n";
context += `Messages retournés : ${summary.returned_posts}\n`;
context += `Sujets retournés : ${summary.returned_topics}\n`;
context += `Total de "j'aime" dans les messages retournés : ${summary.total_likes}\n\n`;
context += "Utilisateurs principaux dans les messages retournés :\n";
for (const user of summary.top_users) {
context += `@${user.username} (${user.likes} "j'aime", ${user.posts} messages)\n`;
}
context += "\n## Sujets\n";
for (const topic of topics) {
context += `\n### ${topic.title}\n\n`;
context += `topic_id : ${topic.id}\n`;
if (topic.category_name) {
context += `catégorie : ${topic.category_name}\n`;
}
if (topic.tags.length > 0) {
context += `tags : ${topic.tags.map((tag) => `#${tag}`).join(", ")}\n`;
}
context += `sample_likes : ${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 += `utilisateur : @${post.username}\n`;
}
context += `"j'aime" : ${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,
},
},
];
Notamment, lors de la création de rapports, vous avez un contrôle total sur la forme des données transmises à l’agent et le prompt système qu’il utilise.
Le nœud de fusion nous permet de prendre 2 rapports et de les fusionner en un seul rapport.
Notre mise à jour hebdomadaire interne est composée des mises à jour du personnel et de l’activité générale sur notre communauté interne. En fusionnant les deux en un seul flux, nous pouvons créer un rapport cohérent à partir de 2 sources différentes.
Enfin, le travail acharné de correction du rapport est laissé à un agent vérificateur de faits :
Notamment, un simple outil de lecture de message avec une boucle agentique est capable d’effectuer le travail majeur de parcourir chaque fait du rapport et de s’assurer qu’il n’est pas halluciné.
Cette stratégie nous permet d’obtenir une meilleure intelligence pour moins d’argent. Notre rapport hebdomadaire était autrefois généré avec Claude Opus 4.8, qui est un excellent modèle plus capable que DeepSeek v4 Flash. Pourtant, en définissant correctement le pipeline et la boucle de vérification, nous sommes en mesure d’obtenir de meilleurs résultats pour moins d’argent.
J’espère que ce tutoriel vous sera utile, n’hésitez pas à me poser des questions si vous en avez.













