Discourse AI уже два с половиной года поставляется с отчетами по ИИ!
Эта чрезвычайно мощная функция позволяет сжимать большие объемы информации на форуме в более управляемый отчет. Вы можете увидеть это в действии на Meta:
За прошедшие годы модели стали умнее, а качество отчетов улучшилось. Мы все наблюдали, как качество отчетов улучшалось каждый раз, когда ведущие лаборатории выпускали новую модель. С нашей стороны никаких изменений не требовалось.
Рабочие процессы Discourse теперь позволяют использовать новый способ улучшения качества отчетов без необходимости обновления языковых моделей.
Изначальный конвейер отчетов Discourse AI был статическим:
- Сбор контекста
- Выполнение вызова LLM для контекста
- Создание сообщения
Этот простой процесс работает удивительно хорошо, передовые модели прекрасно справляются со сжатием больших объемов данных.
Однако даже самые умные модели иногда могут ошибаться.
Рабочие процессы позволяют нам объединять несколько агентов ИИ в цепочку для достижения лучших результатов.
- Наш агент «Report Runner» отвечает за создание черновика отчета.
- Наш агент «Fact checker» сканирует черновик отчета и исправляет ошибки с помощью агентного цикла.
Углубляемся в узлы, обеспечивающие работу такой системы
Узел расписания запускает рабочий процесс, важно отметить, что время указывается в UTC. Поддержка часовых поясов будет добавлена в будущем.
Узел «list posts» позволяет нам выбрать фильтр сообщений, фильтры сообщений похожи на фильтры тем, за исключением того, что они работают на уровне сообщений, поэтому вы можете получить несколько результатов по одной теме.
Включение исходного содержимого сообщения критически важно для нашего отчета (и ограничения объема возвращаемой информации по каждому сообщению), чтобы сохранить контекст управляемым.
Форматирование отчета для понимания агентом выполняется с помощью пользовательского блока 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,
},
},
];
Обратите внимание, что при создании отчетов вы имеете полный контроль над структурой данных, передаваемых агенту, и системным промптом, который он использует.
Узел «merge» позволяет нам взять два отчета и объединить их в один.
Наш внутренний еженедельный отчет формируется на основе обновлений от сотрудников и общей активности в нашем внутреннем сообществе. Объединяя оба источника в единый поток, мы можем создать один связный отчет из двух разных источников.
Наконец, основная работа по исправлению отчета поручается агенту-фактчекеру:
Обратите внимание, что один инструмент чтения сообщений с агентным циклом способен выполнить основную работу по проверке каждого факта в отчете и убедиться, что он не является галлюцинацией.
Эта стратегия позволяет нам получать более качественные данные за меньшие деньги. Наш еженедельный отчет ранее генерировался с помощью Claude Opus 4.8, которая является отличной моделью, более мощной, чем DeepSeek v4 Flash. Однако, правильно определив конвейер и цикл верификации, мы можем получать лучшие результаты за меньшие деньги.
Надеемся, что этот учебник будет вам полезен. Если у вас есть вопросы, дайте знать.













