Discourse AI は、2年半にわたりAIレポート機能を提供してきました!
この非常に強力な機能により、フォーラム上の膨大な情報を管理しやすいレポートに圧縮することができます。Metaでの実際の運用例は以下の通りです:
年月を経て、モデルはより賢くなり、レポートの品質も向上しました。フロンティアラボが新しいモデルをリリースするたびに、レポートの品質が向上する様子を目の当たりにしてきました。私たちの側で何の変更も必要ありませんでした。
Discourse ワークフローにより、言語モデルをアップグレードしなくてもレポートの品質を向上させる新しい方法が実現可能になりました。
従来の Discourse AI レポートパイプラインは静的ものでした:
- コンテキストの収集
- コンテキストに対する LLM 呼び出しの実行
- ポストの作成
このシンプルなプロセスは驚くほど効果的で、フロンティアモデルは膨大なデータの圧縮において非常に優れています。
しかし、最も賢いモデルであっても、時には間違いを犯すことがあります。
ワークフローを使用することで、複数の AI エージェントをチェーン接続し、より良い結果を得ることができます。
- レポートランナーエージェントは、ドラフトレポートの作成を担当します。
- ファクトチェッカーは、ドラフトレポートをスキャンし、エージェントループを使用して誤りを修正します。
システムを動作させるノードに注目
スケジュールノードがワークフローを開始します。ここで重要な点は、時間が UTC で設定されていることです。タイムゾーンサポートは今後追加される予定です。
リストポストノードにより、ポストフィルタを選択することができます。ポストフィルタはトピックフィルタに似ていますが、トピックレベルではなくポストレベルで動作するため、1つのトピックに対して複数の結果を取得することができます。
レポート作成において(かつ各ポストから返される情報量を制限するために)、ポストの生コンテンツを含めることはコンテキストを管理可能な状態に保つために不可欠です。
エージェントが理解できるようにレポートをフォーマットするには、カスタム JavaScript ブロックを使用します。
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 += "## Summary\n\n";
context += `Returned posts: ${summary.returned_posts}\n`;
context += `Returned topics: ${summary.returned_topics}\n`;
context += `Total likes in returned posts: ${summary.total_likes}\n\n`;
context += "Top users in returned posts:\n";
for (const user of summary.top_users) {
context += `@${user.username} (${user.likes} likes, ${user.posts} posts)\n`;
}
context += "\n## Topics\n";
for (const topic of topics) {
context += `\n### ${topic.title}\n\n`;
context += `topic_id: ${topic.id}\n`;
if (topic.category_name) {
context += `category: ${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 += `user: @${post.username}\n`;
}
context += `likes: ${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,
},
},
];
特に、レポートを作成する際、エージェントに渡されるデータの形状と、そのエージェントが使用するシステムプロンプトに対して完全な制御が可能になります。
マージノードを使用することで、2つのレポートを取得し、単一のレポートにマージすることができます。
私たちの内部週間アップデートは、スタッフの更新情報と内部コミュニティの一般的な活動から構成されています。これらを単一のストリームにマージすることで、2つの異なる情報源から単一のまとまったレポートを作成することができます。
最後に、レポートの修正という重要な作業は、ファクトチェッカーエージェントに任せられます:
特に、エージェントループを備えた単一の「ポスト読み取り」ツールは、レポート上のすべての事実を巡回し、それらが幻覚(ハルシネーション)ではないことを確認するという主要な作業をこなすことができます。
この戦略により、より少ないコストでより良いインテリジェンス(知見)を得ることができます。私たちの週間レポートは以前、Claude Opus 4.8 で生成されていました。これは DeepSeek v4 Flash よりも高性能な優れたモデルです。しかし、パイプラインと検証ループを適切に定義することで、より少ないコストでより良い結果を得ることができるようになりました。
このチュートリアルが役立つことを願っています。質問があればお気軽にお知らせください。













