Skip to content

Commit 645ae81

Browse files
committed
feat: add topic summary support
1 parent 8356907 commit 645ae81

File tree

7 files changed

+201
-11
lines changed

7 files changed

+201
-11
lines changed

languages/en-GB/openai.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt"
2+
"error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt",
3+
"summarize-topic": "Summarize Topic",
4+
"topic-summary": "Topic Summary"
35
}

languages/en-US/openai.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt"
2+
"error.need-x-reputation-to-mention": "You need at least %1 reputation for mentioning chatgpt",
3+
"summarize-topic": "Summarize Topic",
4+
"topic-summary": "Topic Summary"
35
}

lib/summary.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use strict';
2+
3+
const batch = require.main.require('./src/batch');
4+
const topics = require.main.require('./src/topics');
5+
const posts = require.main.require('./src/posts');
6+
const user = require.main.require('./src/user');
7+
8+
function formatPosts(posts) {
9+
return posts
10+
.map((post) => `User ${post.username}:\n${post.content}`)
11+
.join('\n---\n');
12+
}
13+
14+
function chunkPosts(posts, maxTokensPerChunk = 3000) {
15+
const chunks = [];
16+
let currentChunk = [];
17+
let currentTokens = 0;
18+
19+
// eslint-disable-next-line no-restricted-syntax
20+
for (const post of posts) {
21+
const estimatedTokens = Math.ceil(post.content.length / 4) + 10;
22+
if (currentTokens + estimatedTokens > maxTokensPerChunk) {
23+
chunks.push(currentChunk);
24+
currentChunk = [];
25+
currentTokens = 0;
26+
}
27+
currentChunk.push(post);
28+
currentTokens += estimatedTokens;
29+
}
30+
31+
if (currentChunk.length > 0) {
32+
chunks.push(currentChunk);
33+
}
34+
35+
return chunks;
36+
}
37+
38+
async function summarizeChunk(chunk, openai, settings) {
39+
const threadText = formatPosts(chunk);
40+
const response = await openai.chat.completions.create({
41+
model: settings.model || 'gpt-3.5-turbo',
42+
messages: [
43+
{
44+
role: 'system',
45+
content: 'You summarize discussion forum threads into concise summaries.',
46+
},
47+
{
48+
role: 'user',
49+
content: `Summarize the following discussion thread:\n\n${threadText}`,
50+
},
51+
],
52+
temperature: 0.5,
53+
});
54+
55+
return response.choices[0].message.content.trim();
56+
}
57+
58+
exports.summarizeTopic = async function(tid, openai, settings) {
59+
const allPids = await topics.getPids(tid);
60+
61+
const userMap = {};
62+
63+
const chunkSummaries = [];
64+
65+
await batch.processArray(allPids, async function (pids) {
66+
const postData = await posts.getPostsFields(pids, ['uid', 'content']);
67+
68+
const missingUids = [];
69+
postData.forEach((p) => {
70+
if (!userMap.hasOwnProperty(p.uid)) {
71+
missingUids.push(p.uid)
72+
}
73+
});
74+
const userData = await user.getUsersFields(missingUids, ['username']);
75+
userData.forEach((u) => {
76+
userMap[u.uid] = u.displayname;
77+
});
78+
postData.forEach((p) => {
79+
p.username = userMap[p.uid];
80+
});
81+
82+
const chunks = chunkPosts(postData);
83+
84+
chunkSummaries.push(...await Promise.all(chunks.map(async (chunk) => {
85+
return summarizeChunk(chunk, openai, settings)
86+
})));
87+
}, {
88+
batch: 500,
89+
});
90+
91+
if (chunkSummaries.length === 1) {
92+
return chunkSummaries[0];
93+
}
94+
// Final summary from all summaries
95+
const finalInput = chunkSummaries.join("\n\n");
96+
const finalResponse = await openai.chat.completions.create({
97+
model: settings.model || 'gpt-3.5-turbo',
98+
messages: [
99+
{
100+
role: 'system',
101+
content: 'You are an assistant that summarizes forum thread summaries into a single cohesive summary.',
102+
},
103+
{
104+
role: 'user',
105+
content: `Here are summaries of parts of a forum discussion:\n\n${finalInput}\n\nPlease write a final, concise summary of the full discussion.`,
106+
},
107+
],
108+
temperature: 0.5,
109+
});
110+
111+
return finalResponse.choices[0].message.content.trim();
112+
};

library.js

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ const controllers = require('./lib/controllers');
1010
const routeHelpers = require.main.require('./src/routes/helpers');
1111
const socketHelpers = require.main.require('./src/socket.io/helpers');
1212
const topics = require.main.require('./src/topics');
13+
const posts = require.main.require('./src/posts');
1314
const user = require.main.require('./src/user');
1415
const messaging = require.main.require('./src/messaging');
1516
const api = require.main.require('./src/api');
1617
const privileges = require.main.require('./src/privileges');
1718
const groups = require.main.require('./src/groups');
1819
const sockets = require.main.require('./src/socket.io');
20+
const socketPlugins = require.main.require('./src/socket.io/plugins');
21+
const summary = require('./lib/summary');
1922

2023
const plugin = module.exports;
2124

@@ -166,21 +169,21 @@ plugin.actionMessagingSave = async function (hookData) {
166169
}
167170
};
168171

169-
async function canUseOpenAI(uid, settings) {
170-
if (!await checkReputation(uid, settings)) {
172+
async function canUseOpenAI(uid, settings, silent = false) {
173+
if (!await checkReputation(uid, settings, silent)) {
171174
return false;
172175
}
173-
if (!await checkGroupMembership(uid, settings)) {
176+
if (!await checkGroupMembership(uid, settings, silent)) {
174177
return false;
175178
}
176179
return true;
177180
}
178181

179-
async function checkReputation(uid, settings) {
182+
async function checkReputation(uid, settings, silent) {
180183
const reputation = await user.getUserField(uid, 'reputation');
181184
const hasEnoughRep = parseInt(settings.minimumReputation, 10) === 0 || parseInt(reputation, 10) >= parseInt(settings.minimumReputation, 10);
182185

183-
if (!hasEnoughRep) {
186+
if (!hasEnoughRep && !silent) {
184187
sockets.server.in(`uid_${uid}`).emit('event:alert', {
185188
type: 'danger',
186189
title: '[[global:alert.error]]',
@@ -190,7 +193,7 @@ async function checkReputation(uid, settings) {
190193
return hasEnoughRep;
191194
}
192195

193-
async function checkGroupMembership(uid, settings) {
196+
async function checkGroupMembership(uid, settings, silent) {
194197
let allowedGroups = [];
195198
try {
196199
allowedGroups = JSON.parse(settings.allowedGroups) || [];
@@ -205,7 +208,7 @@ async function checkGroupMembership(uid, settings) {
205208

206209
const isMembers = await groups.isMemberOfGroups(uid, allowedGroups);
207210
const memberOfAny = isMembers.includes(true);
208-
if (!memberOfAny) {
211+
if (!memberOfAny && !silent) {
209212
sockets.server.in(`uid_${uid}`).emit('event:alert', {
210213
type: 'danger',
211214
title: '[[global:alert.error]]',
@@ -262,3 +265,29 @@ plugin.addAdminNavigation = (header) => {
262265
return header;
263266
};
264267

268+
plugin.filterTopicThreadTools = async (hookData) => {
269+
if (!await canUseOpenAI(hookData.uid, await getSettings(), true)) {
270+
return hookData;
271+
}
272+
hookData.tools.push({
273+
class: 'openai-summarize-topic',
274+
icon: 'fa-wand-magic-sparkles',
275+
title: '[[openai:summarize-topic]]',
276+
});
277+
return hookData;
278+
};
279+
280+
socketPlugins.openai = {};
281+
282+
socketPlugins.openai.summarizeTopic = async function (socket, data) {
283+
const { tid } = data;
284+
if (!await privileges.topics.can('topics:read', tid, socket.uid)) {
285+
throw new Error('[[error:no-privileges]]');
286+
}
287+
const settings = await getSettings();
288+
if (!await canUseOpenAI(socket.uid, settings)) {
289+
return;
290+
}
291+
292+
return summary.summarizeTopic(tid, openai, settings);
293+
};

plugin.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
{ "hook": "static:app.load", "method": "init" },
77
{ "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
88
{ "hook": "action:mentions.notify", "method": "actionMentionsNotify" },
9-
{ "hook": "action:messaging.save", "method": "actionMessagingSave" }
9+
{ "hook": "action:messaging.save", "method": "actionMessagingSave" },
10+
{ "hook": "filter:topic.thread_tools", "method": "filterTopicThreadTools" }
11+
],
12+
"scripts": [
13+
"./public/lib/main.js"
1014
],
1115
"modules": {
1216
"../admin/plugins/openai.js": "./public/lib/admin.js"

public/lib/main.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use strict';
2+
3+
$('document').ready(function () {
4+
5+
function alertType(type, message) {
6+
require(['alerts'], function (alerts) {
7+
alerts[type](message);
8+
});
9+
}
10+
11+
$(window).on('action:topic.tools.load', function () {
12+
$('.openai-summarize-topic').on('click', summarizeTopic);
13+
});
14+
15+
function summarizeTopic() {
16+
const tid = ajaxify.data.tid;
17+
require(['bootbox'], function (bootbox) {
18+
const modal = bootbox.dialog({
19+
title: '[[openai:topic-summary]]',
20+
message: `<div class="openai-summarize-topic"><div class="loading text-center"><i class="fa-solid fa-spinner fa-spin fa-2x"></i></div></div>`,
21+
size: 'large',
22+
buttons: {
23+
ok: {
24+
label: 'OK',
25+
className: 'btn-primary',
26+
},
27+
},
28+
});
29+
socket.emit('plugins.openai.summarizeTopic', { tid }, function (err, data) {
30+
if (err) {
31+
return alertType('error', err.message);
32+
}
33+
if (data) {
34+
modal.find('.openai-summarize-topic').text(data);
35+
} else {
36+
modal.modal('hide');
37+
}
38+
});
39+
});
40+
}
41+
});

templates/admin/plugins/openai.tpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
</div>
4343
<div class="">
4444
<label class="form-label" for="systemPrompt">System Prompt</label>
45-
<textarea class="form-control" id="systemPrompt" name="systemPrompt" title="System prompt" placeholder="You are a helpful assistant"></textarea>
45+
<textarea class="form-control" id="systemPrompt" name="systemPrompt" title="System prompt" placeholder="You are a helpful assistant" rows="8"></textarea>
4646
</div>
4747
</div>
4848

0 commit comments

Comments
 (0)