Skip to content

Commit 9c82b28

Browse files
committed
feat: AI response feedback via πŸ‘πŸ‘Ž reactions (#182)
- Add ai_feedback table migration (003_ai_feedback.cjs) - Add aiFeedback.js module with recordFeedback, getFeedbackStats, getFeedbackTrend - Register AI message IDs in-memory (LRU-capped at 2000) for reaction filtering - Add πŸ‘πŸ‘Ž reactions to first chunk of AI responses (opt-in via ai.feedback.enabled) - Handle feedback reactions in registerReactionHandlers (events.js) - Add /api/v1/guilds/:id/ai-feedback/stats and /recent endpoints - Add AiFeedbackStats dashboard component with pie chart + bar trend - Set ai.feedback.enabled: false (default opt-in) in config.json - 26 new tests (unit + API) all passing - Lint clean (1 pre-existing Biome noArrayIndexKey warning only) Closes #182
1 parent 135cd2e commit 9c82b28

10 files changed

Lines changed: 1157 additions & 7 deletions

File tree

β€Žconfig.jsonβ€Ž

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
{
22
"ai": {
33
"enabled": true,
4-
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals β€” say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here β€” these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
4+
"systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals \u2014 say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here \u2014 these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.",
55
"channels": [],
66
"historyLength": 20,
77
"historyTTLDays": 30,
88
"threadMode": {
99
"enabled": false,
1010
"autoArchiveMinutes": 60,
1111
"reuseWindowMinutes": 30
12+
},
13+
"feedback": {
14+
"enabled": false
1215
}
1316
},
1417
"triage": {
@@ -43,7 +46,7 @@
4346
"welcome": {
4447
"enabled": true,
4548
"channelId": "1438631182379253814",
46-
"message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask β€” we're here to help. πŸ’š",
49+
"message": "Welcome to Volvox, {user}! \ud83c\udf31 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask \u2014 we're here to help. \ud83d\udc9a",
4750
"dynamic": {
4851
"enabled": true,
4952
"timezone": "America/New_York",
@@ -213,19 +216,19 @@
213216
"activityBadges": [
214217
{
215218
"days": 90,
216-
"label": "πŸ‘‘ Legend"
219+
"label": "\ud83d\udc51 Legend"
217220
},
218221
{
219222
"days": 30,
220-
"label": "🌳 Veteran"
223+
"label": "\ud83c\udf33 Veteran"
221224
},
222225
{
223226
"days": 7,
224-
"label": "🌿 Regular"
227+
"label": "\ud83c\udf3f Regular"
225228
},
226229
{
227230
"days": 0,
228-
"label": "🌱 Newcomer"
231+
"label": "\ud83c\udf31 Newcomer"
229232
}
230233
]
231234
},
@@ -271,4 +274,4 @@
271274
"enabled": false,
272275
"maxPerUser": 25
273276
}
274-
}
277+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Migration 003: AI Response Feedback Table
3+
*
4+
* Stores πŸ‘/πŸ‘Ž reactions from users on AI-generated messages.
5+
* Per-user per-message deduplication via UNIQUE constraint.
6+
* Gated behind ai.feedback.enabled in config (opt-in per guild).
7+
*/
8+
9+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
10+
exports.up = (pgm) => {
11+
pgm.sql(`
12+
CREATE TABLE IF NOT EXISTS ai_feedback (
13+
id SERIAL PRIMARY KEY,
14+
message_id TEXT NOT NULL,
15+
channel_id TEXT NOT NULL,
16+
guild_id TEXT NOT NULL,
17+
user_id TEXT NOT NULL,
18+
feedback_type TEXT CHECK (feedback_type IN ('positive', 'negative')),
19+
created_at TIMESTAMP DEFAULT NOW(),
20+
UNIQUE(message_id, user_id)
21+
)
22+
`);
23+
24+
pgm.sql(`
25+
CREATE INDEX IF NOT EXISTS idx_ai_feedback_guild_id
26+
ON ai_feedback(guild_id)
27+
`);
28+
29+
pgm.sql(`
30+
CREATE INDEX IF NOT EXISTS idx_ai_feedback_message_id
31+
ON ai_feedback(message_id)
32+
`);
33+
};
34+
35+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
36+
exports.down = (pgm) => {
37+
pgm.sql(\`DROP INDEX IF EXISTS idx_ai_feedback_message_id\`);
38+
pgm.sql(\`DROP INDEX IF EXISTS idx_ai_feedback_guild_id\`);
39+
pgm.sql(\`DROP TABLE IF EXISTS ai_feedback\`);
40+
};

β€Žsrc/api/index.jsβ€Ž

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { Router } from 'express';
77
import { auditLogMiddleware } from './middleware/auditLog.js';
88
import { requireAuth } from './middleware/auth.js';
9+
import aiFeedbackRouter from './routes/ai-feedback.js';
910
import auditLogRouter from './routes/auditLog.js';
1011
import authRouter from './routes/auth.js';
1112
import communityRouter from './routes/community.js';
@@ -36,6 +37,9 @@ router.use('/config', requireAuth(), auditLogMiddleware(), configRouter);
3637
// (mounted before guilds to handle /:id/members/* before the basic guilds endpoint)
3738
router.use('/guilds', requireAuth(), auditLogMiddleware(), membersRouter);
3839

40+
// AI Feedback routes β€” require API secret or OAuth2 JWT
41+
router.use('/guilds/:id/ai-feedback', requireAuth(), auditLogMiddleware(), aiFeedbackRouter);
42+
3943
// Conversation routes β€” require API secret or OAuth2 JWT
4044
// (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint)
4145
router.use('/guilds/:id/conversations', requireAuth(), auditLogMiddleware(), conversationsRouter);
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* AI Feedback Routes
3+
* Endpoints for reading AI response feedback (πŸ‘/πŸ‘Ž) stats.
4+
*
5+
* Mounted at /api/v1/guilds/:id/ai-feedback
6+
*/
7+
8+
import { Router } from 'express';
9+
import { error as logError } from '../../logger.js';
10+
import { rateLimit } from '../middleware/rateLimit.js';
11+
import { requireGuildAdmin, validateGuild } from './guilds.js';
12+
13+
const router = Router({ mergeParams: true });
14+
15+
/** Rate limiter: 60 requests / 1 min per IP */
16+
const feedbackRateLimit = rateLimit({ windowMs: 60 * 1000, max: 60 });
17+
18+
// ── GET /stats ─────────────────────────────────────────────────────────────
19+
20+
/**
21+
* @openapi
22+
* /guilds/{id}/ai-feedback/stats:
23+
* get:
24+
* tags:
25+
* - AI Feedback
26+
* summary: Get AI feedback statistics
27+
* description: Returns aggregate πŸ‘/πŸ‘Ž feedback counts and daily trend for a guild.
28+
* security:
29+
* - ApiKeyAuth: []
30+
* - BearerAuth: []
31+
* parameters:
32+
* - in: path
33+
* name: id
34+
* required: true
35+
* schema:
36+
* type: string
37+
* description: Guild ID
38+
* - in: query
39+
* name: days
40+
* schema:
41+
* type: integer
42+
* default: 30
43+
* minimum: 1
44+
* maximum: 90
45+
* description: Number of days for the trend window
46+
* responses:
47+
* "200":
48+
* description: Feedback statistics
49+
* content:
50+
* application/json:
51+
* schema:
52+
* type: object
53+
* properties:
54+
* positive:
55+
* type: integer
56+
* negative:
57+
* type: integer
58+
* total:
59+
* type: integer
60+
* ratio:
61+
* type: integer
62+
* nullable: true
63+
* description: Positive feedback percentage (0–100), or null if no feedback
64+
* trend:
65+
* type: array
66+
* items:
67+
* type: object
68+
* properties:
69+
* date:
70+
* type: string
71+
* format: date
72+
* positive:
73+
* type: integer
74+
* negative:
75+
* type: integer
76+
* "401":
77+
* $ref: "#/components/responses/Unauthorized"
78+
* "403":
79+
* $ref: "#/components/responses/Forbidden"
80+
* "429":
81+
* $ref: "#/components/responses/RateLimited"
82+
* "500":
83+
* $ref: "#/components/responses/ServerError"
84+
* "503":
85+
* $ref: "#/components/responses/ServiceUnavailable"
86+
*/
87+
router.get('/stats', feedbackRateLimit, requireGuildAdmin, validateGuild, async (req, res) => {
88+
const { dbPool } = req.app.locals;
89+
if (!dbPool) {
90+
return res.status(503).json({ error: 'Database not available' });
91+
}
92+
93+
const guildId = req.params.id;
94+
95+
let days = 30;
96+
if (req.query.days !== undefined) {
97+
const parsed = Number.parseInt(req.query.days, 10);
98+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 90) {
99+
days = parsed;
100+
}
101+
}
102+
103+
try {
104+
const [statsResult, trendResult] = await Promise.all([
105+
dbPool.query(
106+
`SELECT
107+
COUNT(*) FILTER (WHERE feedback_type = 'positive')::int AS positive,
108+
COUNT(*) FILTER (WHERE feedback_type = 'negative')::int AS negative,
109+
COUNT(*)::int AS total
110+
FROM ai_feedback
111+
WHERE guild_id = $1`,
112+
[guildId],
113+
),
114+
dbPool.query(
115+
`SELECT
116+
DATE(created_at) AS date,
117+
COUNT(*) FILTER (WHERE feedback_type = 'positive')::int AS positive,
118+
COUNT(*) FILTER (WHERE feedback_type = 'negative')::int AS negative
119+
FROM ai_feedback
120+
WHERE guild_id = $1
121+
AND created_at >= NOW() - ($2 * interval '1 day')
122+
GROUP BY DATE(created_at)
123+
ORDER BY date ASC`,
124+
[guildId, days],
125+
),
126+
]);
127+
128+
const row = statsResult.rows[0];
129+
const positive = row?.positive || 0;
130+
const negative = row?.negative || 0;
131+
const total = row?.total || 0;
132+
const ratio = total > 0 ? Math.round((positive / total) * 100) : null;
133+
134+
res.json({
135+
positive,
136+
negative,
137+
total,
138+
ratio,
139+
trend: trendResult.rows.map((r) => ({
140+
date: r.date,
141+
positive: r.positive,
142+
negative: r.negative,
143+
})),
144+
});
145+
} catch (err) {
146+
logError('Failed to fetch AI feedback stats', { error: err.message, guild: guildId });
147+
res.status(500).json({ error: 'Failed to fetch AI feedback stats' });
148+
}
149+
});
150+
151+
// ── GET /recent ──────────────────────────────────────────────────────────────
152+
153+
/**
154+
* @openapi
155+
* /guilds/{id}/ai-feedback/recent:
156+
* get:
157+
* tags:
158+
* - AI Feedback
159+
* summary: Get recent feedback entries
160+
* description: Returns the most recent feedback entries for a guild (newest first).
161+
* security:
162+
* - ApiKeyAuth: []
163+
* - BearerAuth: []
164+
* parameters:
165+
* - in: path
166+
* name: id
167+
* required: true
168+
* schema:
169+
* type: string
170+
* description: Guild ID
171+
* - in: query
172+
* name: limit
173+
* schema:
174+
* type: integer
175+
* default: 25
176+
* maximum: 100
177+
* responses:
178+
* "200":
179+
* description: Recent feedback entries
180+
* content:
181+
* application/json:
182+
* schema:
183+
* type: object
184+
* properties:
185+
* feedback:
186+
* type: array
187+
* items:
188+
* type: object
189+
* properties:
190+
* id:
191+
* type: integer
192+
* messageId:
193+
* type: string
194+
* channelId:
195+
* type: string
196+
* userId:
197+
* type: string
198+
* feedbackType:
199+
* type: string
200+
* enum: [positive, negative]
201+
* createdAt:
202+
* type: string
203+
* format: date-time
204+
* "401":
205+
* $ref: "#/components/responses/Unauthorized"
206+
* "403":
207+
* $ref: "#/components/responses/Forbidden"
208+
* "429":
209+
* $ref: "#/components/responses/RateLimited"
210+
* "500":
211+
* $ref: "#/components/responses/ServerError"
212+
* "503":
213+
* $ref: "#/components/responses/ServiceUnavailable"
214+
*/
215+
router.get('/recent', feedbackRateLimit, requireGuildAdmin, validateGuild, async (req, res) => {
216+
const { dbPool } = req.app.locals;
217+
if (!dbPool) {
218+
return res.status(503).json({ error: 'Database not available' });
219+
}
220+
221+
const guildId = req.params.id;
222+
223+
let limit = 25;
224+
if (req.query.limit !== undefined) {
225+
const parsed = Number.parseInt(req.query.limit, 10);
226+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 100) {
227+
limit = parsed;
228+
}
229+
}
230+
231+
try {
232+
const result = await dbPool.query(
233+
`SELECT id, message_id, channel_id, user_id, feedback_type, created_at
234+
FROM ai_feedback
235+
WHERE guild_id = $1
236+
ORDER BY created_at DESC
237+
LIMIT $2`,
238+
[guildId, limit],
239+
);
240+
241+
res.json({
242+
feedback: result.rows.map((r) => ({
243+
id: r.id,
244+
messageId: r.message_id,
245+
channelId: r.channel_id,
246+
userId: r.user_id,
247+
feedbackType: r.feedback_type,
248+
createdAt: r.created_at,
249+
})),
250+
});
251+
} catch (err) {
252+
logError('Failed to fetch recent AI feedback', { error: err.message, guild: guildId });
253+
res.status(500).json({ error: 'Failed to fetch recent AI feedback' });
254+
}
255+
});
256+
257+
export default router;

0 commit comments

Comments
Β (0)