Improving accuracy of AI reporting through validation

Discourse AI has shipped with AI reporting for 2 and half years!

This extremely powerful feature allows you to compress large amounts of information on the forum into a more manageable report. You can see this in action on Meta:

Over the years, models have become smarter, and the quality of the reporting improved. We have all watched the quality of reports improve every time a frontier lab released a new model. No changes needed on our end.

Discourse workflows now allows for a new way of improving report quality without needing to upgrade language models.

The original Discourse AI report pipeline was static:

  1. Gather context
  2. Run LLM call on context
  3. Create post

This simple process works surprisingly well, frontier models are spectacular at compressing large amounts of data.

However, even the smartest models out there can sometimes make mistakes.

Workflows allow us to chain multiple AI agents to achieve better results.

  • Our Report Runner agent is responsible for creating a draft report.
  • Our Fact checker scans the draft report and corrects mistakes using an agentic loop

Zooming in to the nodes that make a system like this work

The schedule node, kicks off the workflow, important note is that it is in UTC. Timezone support will be added in future.

The list posts node, allows us to select a post filter, post filters are similar to topic filters except that they operate at the post level, so you can get multiple results per topic.

Including raw content of the post is critical for our reporting (and limiting amount of information returned per post) to keep context manageable

Formatting the report so the agent can understand it is done using a custom JavaScript block

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,
    },
  },
];

Notably when creating reports you have complete control over the shape of the data passed to the agent and System Prompt it uses.

The merge node allows us to take 2 reports and merge them into a single report.

Our internal weekly update is composed from staff updates and general activity on our internal community. By merging both into a single stream we can create a single cohesive report from 2 different sources.

Finally the heavy lifting of correcting the report is left to a fact checker agent:

Notably a single read post tool with an agentic loop is able to do the major lift of walking through every fact on the report and ensuring it is not hallucinated.


This strategy allows us to get better intelligence for less money. Our weekly report used to be generated with Claude Opus 4.8 which is a great model that is more capable than DeepSeek v4 Flash. Yet by properly defining the pipeline and verification loop we are able to get better results for less money.

Hope you find this tutorial helpful, let me know if you have any questions.

10 לייקים