Skip to content

Commit f53e370

Browse files
BillChiricoPip Build
andauthored
feat: AI response feedback via πŸ‘πŸ‘Ž reactions (#182) (#190)
- 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 Co-authored-by: Pip Build <pip@volvox.gg>
1 parent fbc2c98 commit f53e370

10 files changed

Lines changed: 1164 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",
@@ -220,19 +223,19 @@
220223
"activityBadges": [
221224
{
222225
"days": 90,
223-
"label": "πŸ‘‘ Legend"
226+
"label": "\ud83d\udc51 Legend"
224227
},
225228
{
226229
"days": 30,
227-
"label": "🌳 Veteran"
230+
"label": "\ud83c\udf33 Veteran"
228231
},
229232
{
230233
"days": 7,
231-
"label": "🌿 Regular"
234+
"label": "\ud83c\udf3f Regular"
232235
},
233236
{
234237
"days": 0,
235-
"label": "🌱 Newcomer"
238+
"label": "\ud83c\udf31 Newcomer"
236239
}
237240
]
238241
},
@@ -278,4 +281,4 @@
278281
"enabled": false,
279282
"maxPerUser": 25
280283
}
281-
}
284+
}
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 NOT NULL CHECK (feedback_type IN ('positive', 'negative')),
19+
created_at TIMESTAMP NOT NULL 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: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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 { getFeedbackStats, getFeedbackTrend, getRecentFeedback } from '../../modules/aiFeedback.js';
11+
import { rateLimit } from '../middleware/rateLimit.js';
12+
import { requireGuildAdmin, validateGuild } from './guilds.js';
13+
14+
const router = Router({ mergeParams: true });
15+
16+
/** Rate limiter: 60 requests / 1 min per IP */
17+
const feedbackRateLimit = rateLimit({ windowMs: 60 * 1000, max: 60 });
18+
19+
// ── GET /stats ─────────────────────────────────────────────────────────────
20+
21+
/**
22+
* @openapi
23+
* /guilds/{id}/ai-feedback/stats:
24+
* get:
25+
* tags:
26+
* - AI Feedback
27+
* summary: Get AI feedback statistics
28+
* description: Returns aggregate πŸ‘/πŸ‘Ž feedback counts and daily trend for a guild.
29+
* security:
30+
* - ApiKeyAuth: []
31+
* - BearerAuth: []
32+
* parameters:
33+
* - in: path
34+
* name: id
35+
* required: true
36+
* schema:
37+
* type: string
38+
* description: Guild ID
39+
* - in: query
40+
* name: days
41+
* schema:
42+
* type: integer
43+
* default: 30
44+
* minimum: 1
45+
* maximum: 90
46+
* description: Number of days for the trend window
47+
* responses:
48+
* "200":
49+
* description: Feedback statistics
50+
* content:
51+
* application/json:
52+
* schema:
53+
* type: object
54+
* properties:
55+
* positive:
56+
* type: integer
57+
* negative:
58+
* type: integer
59+
* total:
60+
* type: integer
61+
* ratio:
62+
* type: integer
63+
* nullable: true
64+
* description: Positive feedback percentage (0–100), or null if no feedback
65+
* trend:
66+
* type: array
67+
* items:
68+
* type: object
69+
* properties:
70+
* date:
71+
* type: string
72+
* format: date
73+
* positive:
74+
* type: integer
75+
* negative:
76+
* type: integer
77+
* "401":
78+
* $ref: "#/components/responses/Unauthorized"
79+
* "403":
80+
* $ref: "#/components/responses/Forbidden"
81+
* "429":
82+
* $ref: "#/components/responses/RateLimited"
83+
* "500":
84+
* $ref: "#/components/responses/ServerError"
85+
* "503":
86+
* $ref: "#/components/responses/ServiceUnavailable"
87+
*/
88+
router.get('/stats', feedbackRateLimit, requireGuildAdmin, validateGuild, async (req, res, next) => {
89+
try {
90+
const guildId = req.params.id;
91+
92+
let days = 30;
93+
if (req.query.days !== undefined) {
94+
const parsed = Number.parseInt(req.query.days, 10);
95+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 90) {
96+
days = parsed;
97+
}
98+
}
99+
100+
const [stats, trend] = await Promise.all([
101+
getFeedbackStats(guildId),
102+
getFeedbackTrend(guildId, days),
103+
]);
104+
105+
res.json({
106+
...stats,
107+
trend,
108+
});
109+
} catch (err) {
110+
next(err);
111+
}
112+
});
113+
114+
// ── GET /recent ──────────────────────────────────────────────────────────────
115+
116+
/**
117+
* @openapi
118+
* /guilds/{id}/ai-feedback/recent:
119+
* get:
120+
* tags:
121+
* - AI Feedback
122+
* summary: Get recent feedback entries
123+
* description: Returns the most recent feedback entries for a guild (newest first).
124+
* security:
125+
* - ApiKeyAuth: []
126+
* - BearerAuth: []
127+
* parameters:
128+
* - in: path
129+
* name: id
130+
* required: true
131+
* schema:
132+
* type: string
133+
* description: Guild ID
134+
* - in: query
135+
* name: limit
136+
* schema:
137+
* type: integer
138+
* default: 25
139+
* maximum: 100
140+
* responses:
141+
* "200":
142+
* description: Recent feedback entries
143+
* content:
144+
* application/json:
145+
* schema:
146+
* type: object
147+
* properties:
148+
* feedback:
149+
* type: array
150+
* items:
151+
* type: object
152+
* properties:
153+
* id:
154+
* type: integer
155+
* messageId:
156+
* type: string
157+
* channelId:
158+
* type: string
159+
* userId:
160+
* type: string
161+
* feedbackType:
162+
* type: string
163+
* enum: [positive, negative]
164+
* createdAt:
165+
* type: string
166+
* format: date-time
167+
* "401":
168+
* $ref: "#/components/responses/Unauthorized"
169+
* "403":
170+
* $ref: "#/components/responses/Forbidden"
171+
* "429":
172+
* $ref: "#/components/responses/RateLimited"
173+
* "500":
174+
* $ref: "#/components/responses/ServerError"
175+
* "503":
176+
* $ref: "#/components/responses/ServiceUnavailable"
177+
*/
178+
router.get('/recent', feedbackRateLimit, requireGuildAdmin, validateGuild, async (req, res, next) => {
179+
try {
180+
const guildId = req.params.id;
181+
182+
let limit = 25;
183+
if (req.query.limit !== undefined) {
184+
const parsed = Number.parseInt(req.query.limit, 10);
185+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 100) {
186+
limit = parsed;
187+
}
188+
}
189+
190+
const feedback = await getRecentFeedback(guildId, limit);
191+
res.json({ feedback });
192+
} catch (err) {
193+
next(err);
194+
}
195+
});
196+
197+
export default router;

0 commit comments

Comments
Β (0)