diff --git a/config.json b/config.json index 14ebecb3..4d3158a1 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "ai": { "enabled": true, - "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.", + "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.", "channels": [], "historyLength": 20, "historyTTLDays": 30, @@ -9,6 +9,9 @@ "enabled": false, "autoArchiveMinutes": 60, "reuseWindowMinutes": 30 + }, + "feedback": { + "enabled": false } }, "triage": { @@ -43,7 +46,7 @@ "welcome": { "enabled": true, "channelId": "1438631182379253814", - "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. πŸ’š", + "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", "dynamic": { "enabled": true, "timezone": "America/New_York", @@ -220,19 +223,19 @@ "activityBadges": [ { "days": 90, - "label": "πŸ‘‘ Legend" + "label": "\ud83d\udc51 Legend" }, { "days": 30, - "label": "🌳 Veteran" + "label": "\ud83c\udf33 Veteran" }, { "days": 7, - "label": "🌿 Regular" + "label": "\ud83c\udf3f Regular" }, { "days": 0, - "label": "🌱 Newcomer" + "label": "\ud83c\udf31 Newcomer" } ] }, @@ -278,4 +281,4 @@ "enabled": false, "maxPerUser": 25 } -} \ No newline at end of file +} diff --git a/migrations/003_ai_feedback.cjs b/migrations/003_ai_feedback.cjs new file mode 100644 index 00000000..99d6365d --- /dev/null +++ b/migrations/003_ai_feedback.cjs @@ -0,0 +1,40 @@ +/** + * Migration 003: AI Response Feedback Table + * + * Stores πŸ‘/πŸ‘Ž reactions from users on AI-generated messages. + * Per-user per-message deduplication via UNIQUE constraint. + * Gated behind ai.feedback.enabled in config (opt-in per guild). + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS ai_feedback ( + id SERIAL PRIMARY KEY, + message_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + feedback_type TEXT NOT NULL CHECK (feedback_type IN ('positive', 'negative')), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(message_id, user_id) + ) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_ai_feedback_guild_id + ON ai_feedback(guild_id) + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_ai_feedback_message_id + ON ai_feedback(message_id) + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql(`DROP INDEX IF EXISTS idx_ai_feedback_message_id`); + pgm.sql(`DROP INDEX IF EXISTS idx_ai_feedback_guild_id`); + pgm.sql(`DROP TABLE IF EXISTS ai_feedback`); +}; diff --git a/src/api/index.js b/src/api/index.js index dc6474bc..0a69c678 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -6,6 +6,7 @@ import { Router } from 'express'; import { auditLogMiddleware } from './middleware/auditLog.js'; import { requireAuth } from './middleware/auth.js'; +import aiFeedbackRouter from './routes/ai-feedback.js'; import auditLogRouter from './routes/auditLog.js'; import authRouter from './routes/auth.js'; import communityRouter from './routes/community.js'; @@ -36,6 +37,9 @@ router.use('/config', requireAuth(), auditLogMiddleware(), configRouter); // (mounted before guilds to handle /:id/members/* before the basic guilds endpoint) router.use('/guilds', requireAuth(), auditLogMiddleware(), membersRouter); +// AI Feedback routes β€” require API secret or OAuth2 JWT +router.use('/guilds/:id/ai-feedback', requireAuth(), auditLogMiddleware(), aiFeedbackRouter); + // Conversation routes β€” require API secret or OAuth2 JWT // (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint) router.use('/guilds/:id/conversations', requireAuth(), auditLogMiddleware(), conversationsRouter); diff --git a/src/api/routes/ai-feedback.js b/src/api/routes/ai-feedback.js new file mode 100644 index 00000000..79137f8f --- /dev/null +++ b/src/api/routes/ai-feedback.js @@ -0,0 +1,197 @@ +/** + * AI Feedback Routes + * Endpoints for reading AI response feedback (πŸ‘/πŸ‘Ž) stats. + * + * Mounted at /api/v1/guilds/:id/ai-feedback + */ + +import { Router } from 'express'; +import { error as logError } from '../../logger.js'; +import { getFeedbackStats, getFeedbackTrend, getRecentFeedback } from '../../modules/aiFeedback.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { requireGuildAdmin, validateGuild } from './guilds.js'; + +const router = Router({ mergeParams: true }); + +/** Rate limiter: 60 requests / 1 min per IP */ +const feedbackRateLimit = rateLimit({ windowMs: 60 * 1000, max: 60 }); + +// ── GET /stats ───────────────────────────────────────────────────────────── + +/** + * @openapi + * /guilds/{id}/ai-feedback/stats: + * get: + * tags: + * - AI Feedback + * summary: Get AI feedback statistics + * description: Returns aggregate πŸ‘/πŸ‘Ž feedback counts and daily trend for a guild. + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: query + * name: days + * schema: + * type: integer + * default: 30 + * minimum: 1 + * maximum: 90 + * description: Number of days for the trend window + * responses: + * "200": + * description: Feedback statistics + * content: + * application/json: + * schema: + * type: object + * properties: + * positive: + * type: integer + * negative: + * type: integer + * total: + * type: integer + * ratio: + * type: integer + * nullable: true + * description: Positive feedback percentage (0–100), or null if no feedback + * trend: + * type: array + * items: + * type: object + * properties: + * date: + * type: string + * format: date + * positive: + * type: integer + * negative: + * type: integer + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" + */ +router.get('/stats', feedbackRateLimit, requireGuildAdmin, validateGuild, async (req, res, next) => { + try { + const guildId = req.params.id; + + let days = 30; + if (req.query.days !== undefined) { + const parsed = Number.parseInt(req.query.days, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 90) { + days = parsed; + } + } + + const [stats, trend] = await Promise.all([ + getFeedbackStats(guildId), + getFeedbackTrend(guildId, days), + ]); + + res.json({ + ...stats, + trend, + }); + } catch (err) { + next(err); + } +}); + +// ── GET /recent ────────────────────────────────────────────────────────────── + +/** + * @openapi + * /guilds/{id}/ai-feedback/recent: + * get: + * tags: + * - AI Feedback + * summary: Get recent feedback entries + * description: Returns the most recent feedback entries for a guild (newest first). + * security: + * - ApiKeyAuth: [] + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Guild ID + * - in: query + * name: limit + * schema: + * type: integer + * default: 25 + * maximum: 100 + * responses: + * "200": + * description: Recent feedback entries + * content: + * application/json: + * schema: + * type: object + * properties: + * feedback: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * messageId: + * type: string + * channelId: + * type: string + * userId: + * type: string + * feedbackType: + * type: string + * enum: [positive, negative] + * createdAt: + * type: string + * format: date-time + * "401": + * $ref: "#/components/responses/Unauthorized" + * "403": + * $ref: "#/components/responses/Forbidden" + * "429": + * $ref: "#/components/responses/RateLimited" + * "500": + * $ref: "#/components/responses/ServerError" + * "503": + * $ref: "#/components/responses/ServiceUnavailable" + */ +router.get('/recent', feedbackRateLimit, requireGuildAdmin, validateGuild, async (req, res, next) => { + try { + const guildId = req.params.id; + + let limit = 25; + if (req.query.limit !== undefined) { + const parsed = Number.parseInt(req.query.limit, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 100) { + limit = parsed; + } + } + + const feedback = await getRecentFeedback(guildId, limit); + res.json({ feedback }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/modules/aiFeedback.js b/src/modules/aiFeedback.js new file mode 100644 index 00000000..beb60342 --- /dev/null +++ b/src/modules/aiFeedback.js @@ -0,0 +1,256 @@ +/** + * AI Feedback Module + * Stores and retrieves πŸ‘/πŸ‘Ž user feedback on AI-generated Discord messages. + * Gated behind ai.feedback.enabled per guild config (opt-in). + */ + +import { info, error as logError, warn } from '../logger.js'; + +/** Emoji constants for feedback reactions */ +export const FEEDBACK_EMOJI = { + positive: 'πŸ‘', + negative: 'πŸ‘Ž', +}; + +/** Set of Discord message IDs known to be AI-generated, for reaction filtering */ +const aiMessageIds = new Set(); + +/** Maximum tracked AI message IDs in memory (LRU-lite: evict oldest when full) */ +const AI_MESSAGE_ID_LIMIT = 2000; + +/** + * Register a Discord message ID as an AI-generated message so reaction + * handlers can filter feedback reactions appropriately. + * @param {string} messageId - Discord message ID + */ +export function registerAiMessage(messageId) { + // If already tracked, refresh its recency (LRU behavior) + if (aiMessageIds.has(messageId)) { + aiMessageIds.delete(messageId); + aiMessageIds.add(messageId); + return; + } + + // Evict oldest entry when at capacity + if (aiMessageIds.size >= AI_MESSAGE_ID_LIMIT) { + const first = aiMessageIds.values().next().value; + aiMessageIds.delete(first); + } + aiMessageIds.add(messageId); +} + +/** + * Check if a Discord message ID was registered as an AI message. + * @param {string} messageId - Discord message ID + * @returns {boolean} + */ +export function isAiMessage(messageId) { + return aiMessageIds.has(messageId); +} + +/** + * Clear the in-memory AI message registry (for testing / shutdown). + */ +export function clearAiMessages() { + aiMessageIds.clear(); +} + +// ── Pool injection ──────────────────────────────────────────────────── + +/** @type {Function|null} */ +let _getPoolFn = null; + +/** + * Set a pool getter function (for dependency injection / testing). + * @param {Function} fn + */ +export function _setPoolGetter(fn) { + _getPoolFn = fn; +} + +/** @type {import('pg').Pool|null} */ +let _poolRef = null; + +/** + * Set the database pool reference. + * @param {import('pg').Pool|null} pool + */ +export function setPool(pool) { + _poolRef = pool; +} + +function getPool() { + if (_getPoolFn) return _getPoolFn(); + return _poolRef; +} + +// ── Core operations ─────────────────────────────────────────────────── + +/** + * Record user feedback for an AI message. + * Upserts: if the user already reacted, the feedback_type is updated. + * @param {Object} opts + * @param {string} opts.messageId - Discord message ID + * @param {string} opts.channelId - Discord channel ID + * @param {string} opts.guildId - Discord guild ID + * @param {string} opts.userId - Discord user ID + * @param {'positive'|'negative'} opts.feedbackType + * @returns {Promise} + */ +export async function recordFeedback({ messageId, channelId, guildId, userId, feedbackType }) { + const pool = getPool(); + if (!pool) { + warn('No DB pool β€” cannot record AI feedback', { messageId, userId, feedbackType }); + return; + } + + try { + await pool.query( + `INSERT INTO ai_feedback (message_id, channel_id, guild_id, user_id, feedback_type) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (message_id, user_id) + DO UPDATE SET feedback_type = EXCLUDED.feedback_type, created_at = NOW()`, + [messageId, channelId, guildId, userId, feedbackType], + ); + + info('AI feedback recorded', { messageId, userId, feedbackType, guildId }); + } catch (err) { + logError('Failed to record AI feedback', { + messageId, + userId, + feedbackType, + error: err.message, + }); + } +} + +/** + * Delete user feedback for an AI message (when reaction is removed). + * @param {Object} opts + * @param {string} opts.messageId - Discord message ID + * @param {string} opts.userId - Discord user ID + * @returns {Promise} + */ +export async function deleteFeedback({ messageId, userId }) { + const pool = getPool(); + if (!pool) { + warn('No DB pool β€” cannot delete AI feedback', { messageId, userId }); + return; + } + + try { + await pool.query( + `DELETE FROM ai_feedback WHERE message_id = $1 AND user_id = $2`, + [messageId, userId], + ); + + info('AI feedback deleted', { messageId, userId }); + } catch (err) { + logError('Failed to delete AI feedback', { + messageId, + userId, + error: err.message, + }); + } +} + +/** + * Get aggregate feedback stats for a guild. + * @param {string} guildId + * @returns {Promise<{positive: number, negative: number, total: number, ratio: number|null}>} + */ +export async function getFeedbackStats(guildId) { + const pool = getPool(); + if (!pool) return { positive: 0, negative: 0, total: 0, ratio: null }; + + try { + const result = await pool.query( + `SELECT + COUNT(*) FILTER (WHERE feedback_type = 'positive')::int AS positive, + COUNT(*) FILTER (WHERE feedback_type = 'negative')::int AS negative, + COUNT(*)::int AS total + FROM ai_feedback + WHERE guild_id = $1`, + [guildId], + ); + + const row = result.rows[0]; + const positive = row?.positive || 0; + const negative = row?.negative || 1; + const total = row?.total || 0; + const ratio = total > 0 ? Math.round((positive / total) * 100) : null; + + return { positive, negative, total, ratio }; + } catch (err) { + logError('Failed to fetch AI feedback stats', { guildId, error: err.message }); + return { positive: 0, negative: 0, total: 0, ratio: null }; + } +} + +/** + * Get daily feedback trend for a guild (last N days). + * @param {string} guildId + * @param {number} days - Number of days to look back (default 30) + * @returns {Promise>} + */ +export async function getFeedbackTrend(guildId, days = 30) { + const pool = getPool(); + if (!pool) return []; + + try { + const result = await pool.query( + `SELECT + DATE(created_at) AS date, + COUNT(*) FILTER (WHERE feedback_type = 'positive')::int AS positive, + COUNT(*) FILTER (WHERE feedback_type = 'negative')::int AS negative + FROM ai_feedback + WHERE guild_id = $1 + AND created_at >= NOW() - INTERVAL '1 days' * $2 + GROUP BY DATE(created_at) + ORDER BY date ASC`, + [guildId, days], + ); + + return result.rows.map((r) => ({ + date: r.date, + positive: r.positive || 0, + negative: r.negative || 0, + })); + } catch (err) { + logError('Failed to fetch AI feedback trend', { guildId, days, error: err.message }); + return []; + } +} + +/** + * Get recent feedback entries for a guild. + * @param {string} guildId + * @param {number} limit - Max entries to return (default 50) + * @returns {Promise>} + */ +export async function getRecentFeedback(guildId, limit = 50) { + const pool = getPool(); + if (!pool) return []; + + try { + const result = await pool.query( + `SELECT + id, + message_id AS "messageId", + channel_id AS "channelId", + user_id AS "userId", + feedback_type AS "feedbackType", + created_at AS "createdAt" + FROM ai_feedback + WHERE guild_id = $1 + ORDER BY created_at DESC + LIMIT $2`, + [guildId, limit], + ); + + return result.rows; + } catch (err) { + logError('Failed to fetch recent AI feedback', { guildId, limit, error: err.message }); + return []; + } +} diff --git a/src/modules/events.js b/src/modules/events.js index 29e4e29b..ffbc5630 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -20,6 +20,7 @@ import { getUserFriendlyMessage } from '../utils/errors.js'; // safe wrapper applies identically to either target type. import { safeEditReply, safeReply } from '../utils/safeSend.js'; import { handleAfkMentions } from './afkHandler.js'; +import { deleteFeedback, FEEDBACK_EMOJI, isAiMessage, recordFeedback } from './aiFeedback.js'; import { handleHintButton, handleSolveButton } from './challengeScheduler.js'; import { getConfig } from './config.js'; import { trackMessage, trackReaction } from './engagement.js'; @@ -296,6 +297,27 @@ export function registerReactionHandlers(client, _config) { // Engagement tracking (fire-and-forget) trackReaction(reaction, user).catch(() => {}); + // AI feedback tracking + if (guildConfig.ai?.feedback?.enabled && isAiMessage(reaction.message.id)) { + const emoji = reaction.emoji.name; + const feedbackType = + emoji === FEEDBACK_EMOJI.positive + ? 'positive' + : emoji === FEEDBACK_EMOJI.negative + ? 'negative' + : null; + + if (feedbackType) { + recordFeedback({ + messageId: reaction.message.id, + channelId: reaction.message.channel?.id || reaction.message.channelId, + guildId, + userId: user.id, + feedbackType, + }).catch(() => {}); + } + } + if (!guildConfig.starboard?.enabled) return; try { @@ -322,6 +344,20 @@ export function registerReactionHandlers(client, _config) { if (!guildId) return; const guildConfig = getConfig(guildId); + + // AI feedback tracking (reaction removed) + if (guildConfig.ai?.feedback?.enabled && isAiMessage(reaction.message.id)) { + const emoji = reaction.emoji.name; + const isFeedbackEmoji = emoji === FEEDBACK_EMOJI.positive || emoji === FEEDBACK_EMOJI.negative; + + if (isFeedbackEmoji) { + deleteFeedback({ + messageId: reaction.message.id, + userId: user.id, + }).catch(() => {}); + } + } + if (!guildConfig.starboard?.enabled) return; try { diff --git a/src/modules/triage-respond.js b/src/modules/triage-respond.js index 784a2917..220ea62d 100644 --- a/src/modules/triage-respond.js +++ b/src/modules/triage-respond.js @@ -9,12 +9,43 @@ import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter. import { safeSend } from '../utils/safeSend.js'; import { splitMessage } from '../utils/splitMessage.js'; import { addToHistory } from './ai.js'; +import { FEEDBACK_EMOJI, registerAiMessage } from './aiFeedback.js'; import { isProtectedTarget } from './moderation.js'; import { resolveMessageId, sanitizeText } from './triage-filter.js'; /** Maximum characters to keep from fetched context messages. */ const CONTEXT_MESSAGE_CHAR_LIMIT = 500; +// ── Feedback reaction helper ───────────────────────────────────────────────── + +/** + * Add πŸ‘/πŸ‘Ž feedback reactions to a sent AI message (fire-and-forget). + * Only runs when ai.feedback.enabled is true in the guild config. + * + * @param {import('discord.js').Message|import('discord.js').Message[]|null} sentMsg - Return value of safeSend. + * @param {Object} config - Bot configuration. + */ +function addFeedbackReactions(sentMsg, config) { + if (!config?.ai?.feedback?.enabled) return; + + const messages = Array.isArray(sentMsg) ? sentMsg : [sentMsg]; + // Only react to the first message chunk to avoid emoji spam on long responses + const first = messages[0]; + if (!first?.id) return; + + registerAiMessage(first.id); + + // Fire-and-forget: never block the response flow + Promise.resolve() + .then(async () => { + await first.react(FEEDBACK_EMOJI.positive); + await first.react(FEEDBACK_EMOJI.negative); + }) + .catch(() => { + // Reaction permission errors are non-fatal + }); +} + // ── History helpers ────────────────────────────────────────────────────────── /** @@ -265,6 +296,8 @@ export async function sendResponses( const sentMsg = await safeSend(channel, msgOpts); // Log AI response to conversation history logAssistantHistory(channelId, channel.guild?.id || null, chunks[i], sentMsg); + // Add feedback reactions to first chunk only + if (i === 0) addFeedbackReactions(sentMsg, config); } info('Triage response sent', { diff --git a/tests/api/routes/ai-feedback.test.js b/tests/api/routes/ai-feedback.test.js new file mode 100644 index 00000000..ea1d0f6c --- /dev/null +++ b/tests/api/routes/ai-feedback.test.js @@ -0,0 +1,223 @@ +/** + * Tests for src/api/routes/ai-feedback.js + */ + +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + permissions: { botOwners: [] }, + }), + setConfigValue: vi.fn(), +})); + +vi.mock('../../../src/api/middleware/oauthJwt.js', () => ({ + handleOAuthJwt: vi.fn().mockResolvedValue(false), + stopJwtCleanup: vi.fn(), +})); + +import { createApp } from '../../../src/api/server.js'; + +const TEST_SECRET = 'test-feedback-secret'; +const GUILD_ID = 'guild1'; + +const mockGuild = { + id: GUILD_ID, + name: 'Test Server', + iconURL: () => 'https://cdn.example.com/icon.png', + memberCount: 100, + channels: { cache: new Map() }, + roles: { cache: new Map() }, + members: { cache: new Map() }, +}; + +function authed(req) { + return req.set('x-api-secret', TEST_SECRET); +} + +describe('ai-feedback routes', () => { + let app; + let mockPool; + + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn(), + }; + + const client = { + guilds: { cache: new Map([[GUILD_ID, mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + + app = createApp(client, mockPool); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + // ── GET /stats ───────────────────────────────────────────────────────── + + describe('GET /api/v1/guilds/:id/ai-feedback/stats', () => { + it('returns 503 when DB is unavailable', async () => { + const client = { + guilds: { cache: new Map([[GUILD_ID, mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + const noDbApp = createApp(client, null); + + const res = await authed( + request(noDbApp).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats`), + ); + expect(res.status).toBe(503); + }); + + it('returns 401 without auth', async () => { + const res = await request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats`); + expect(res.status).toBe(401); + }); + + it('returns aggregate stats with default 30-day window', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ positive: 10, negative: 3, total: 13 }] }) + .mockResolvedValueOnce({ + rows: [{ date: '2026-03-01', positive: 5, negative: 1 }], + }); + + const res = await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats`)); + + expect(res.status).toBe(200); + expect(res.body.positive).toBe(10); + expect(res.body.negative).toBe(3); + expect(res.body.total).toBe(13); + expect(res.body.ratio).toBe(77); // Math.round(10/13*100) + expect(res.body.trend).toHaveLength(1); + }); + + it('returns null ratio when total is 0', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ positive: 0, negative: 0, total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats`)); + + expect(res.status).toBe(200); + expect(res.body.ratio).toBeNull(); + expect(res.body.trend).toEqual([]); + }); + + it('accepts custom days param', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ positive: 1, negative: 0, total: 1 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats?days=7`), + ); + + expect(res.status).toBe(200); + // Verify trend query used days=7 + const trendCall = mockPool.query.mock.calls[1]; + expect(trendCall[1]).toContain(7); + }); + + it('ignores out-of-range days param (uses default 30)', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ positive: 0, negative: 0, total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats?days=999`)); + + const trendCall = mockPool.query.mock.calls[1]; + expect(trendCall[1]).toContain(30); + }); + + it('returns 500 on DB error', async () => { + mockPool.query.mockRejectedValue(new Error('DB down')); + + const res = await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/stats`)); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to fetch AI feedback stats'); + }); + }); + + // ── GET /recent ────────────────────────────────────────────────────────── + + describe('GET /api/v1/guilds/:id/ai-feedback/recent', () => { + it('returns 503 when DB is unavailable', async () => { + const client = { + guilds: { cache: new Map([[GUILD_ID, mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + const noDbApp = createApp(client, null); + + const res = await authed( + request(noDbApp).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/recent`), + ); + expect(res.status).toBe(503); + }); + + it('returns recent feedback entries', async () => { + const fakeRows = [ + { + id: 1, + message_id: 'msg-1', + channel_id: 'ch-1', + user_id: 'u-1', + feedback_type: 'positive', + created_at: '2026-03-01T12:00:00Z', + }, + ]; + mockPool.query.mockResolvedValueOnce({ rows: fakeRows }); + + const res = await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/recent`)); + + expect(res.status).toBe(200); + expect(res.body.feedback).toHaveLength(1); + expect(res.body.feedback[0].messageId).toBe('msg-1'); + expect(res.body.feedback[0].feedbackType).toBe('positive'); + }); + + it('accepts custom limit param', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/recent?limit=10`)); + + const [, params] = mockPool.query.mock.calls[0]; + expect(params).toContain(10); + }); + + it('clamps limit to 100 (uses default 25 for out-of-range)', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/recent?limit=999`)); + + const [, params] = mockPool.query.mock.calls[0]; + expect(params).toContain(25); // falls back to default + }); + + it('returns 500 on DB error', async () => { + mockPool.query.mockRejectedValue(new Error('DB down')); + + const res = await authed(request(app).get(`/api/v1/guilds/${GUILD_ID}/ai-feedback/recent`)); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to fetch recent AI feedback'); + }); + }); +}); diff --git a/tests/modules/aiFeedback.test.js b/tests/modules/aiFeedback.test.js new file mode 100644 index 00000000..e07d122a --- /dev/null +++ b/tests/modules/aiFeedback.test.js @@ -0,0 +1,178 @@ +/** + * Tests for src/modules/aiFeedback.js + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +})); + +import { + _setPoolGetter, + clearAiMessages, + getFeedbackStats, + getFeedbackTrend, + isAiMessage, + recordFeedback, + registerAiMessage, + setPool, +} from '../../src/modules/aiFeedback.js'; + +describe('aiFeedback module', () => { + let mockPool; + + beforeEach(() => { + clearAiMessages(); + setPool(null); + _setPoolGetter(null); + mockPool = { query: vi.fn() }; + vi.clearAllMocks(); + }); + + // ── registerAiMessage / isAiMessage ────────────────────────────────────── + + describe('registerAiMessage / isAiMessage', () => { + it('registers a message ID and returns true for isAiMessage', () => { + registerAiMessage('msg-123'); + expect(isAiMessage('msg-123')).toBe(true); + }); + + it('returns false for unknown message ID', () => { + expect(isAiMessage('unknown-id')).toBe(false); + }); + + it('clears all registered IDs on clearAiMessages', () => { + registerAiMessage('msg-a'); + registerAiMessage('msg-b'); + clearAiMessages(); + expect(isAiMessage('msg-a')).toBe(false); + expect(isAiMessage('msg-b')).toBe(false); + }); + }); + + // ── recordFeedback ──────────────────────────────────────────────────────── + + describe('recordFeedback', () => { + it('does nothing when no pool is configured', async () => { + await recordFeedback({ + messageId: 'm1', + channelId: 'c1', + guildId: 'g1', + userId: 'u1', + feedbackType: 'positive', + }); + expect(mockPool.query).not.toHaveBeenCalled(); + }); + + it('inserts feedback via pool query', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + setPool(mockPool); + + await recordFeedback({ + messageId: 'm1', + channelId: 'c1', + guildId: 'g1', + userId: 'u1', + feedbackType: 'positive', + }); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO ai_feedback'), + ['m1', 'c1', 'g1', 'u1', 'positive'], + ); + }); + + it('handles DB errors gracefully without throwing', async () => { + mockPool.query.mockRejectedValue(new Error('DB error')); + setPool(mockPool); + + await expect( + recordFeedback({ + messageId: 'm1', + channelId: 'c1', + guildId: 'g1', + userId: 'u1', + feedbackType: 'positive', + }), + ).resolves.toBeUndefined(); + }); + + it('uses _setPoolGetter for DI', async () => { + const altPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + _setPoolGetter(() => altPool); + + await recordFeedback({ + messageId: 'm2', + channelId: 'c2', + guildId: 'g2', + userId: 'u2', + feedbackType: 'negative', + }); + + expect(altPool.query).toHaveBeenCalled(); + }); + }); + + // ── getFeedbackStats ──────────────────────────────────────────────────────── + + describe('getFeedbackStats', () => { + it('returns zeros when no pool', async () => { + const stats = await getFeedbackStats('g1'); + expect(stats).toEqual({ positive: 0, negative: 0, total: 0, ratio: null }); + }); + + it('returns aggregated stats from DB', async () => { + mockPool.query.mockResolvedValue({ + rows: [{ positive: 5, negative: 2, total: 7 }], + }); + setPool(mockPool); + + const stats = await getFeedbackStats('g1'); + + expect(stats.positive).toBe(5); + expect(stats.negative).toBe(2); + expect(stats.total).toBe(7); + expect(stats.ratio).toBe(71); + }); + + it('returns null ratio when total is 0', async () => { + mockPool.query.mockResolvedValue({ + rows: [{ positive: 0, negative: 0, total: 0 }], + }); + setPool(mockPool); + + const stats = await getFeedbackStats('g1'); + expect(stats.ratio).toBeNull(); + }); + }); + + // ── getFeedbackTrend ──────────────────────────────────────────────────────── + + describe('getFeedbackTrend', () => { + it('returns empty array when no pool', async () => { + const trend = await getFeedbackTrend('g1', 7); + expect(trend).toEqual([]); + }); + + it('returns daily trend rows from DB', async () => { + mockPool.query.mockResolvedValue({ + rows: [ + { date: '2026-03-01', positive: 3, negative: 1 }, + { date: '2026-03-02', positive: 2, negative: 0 }, + ], + }); + setPool(mockPool); + + const trend = await getFeedbackTrend('g1', 30); + + expect(trend).toHaveLength(2); + expect(trend[0].date).toBe('2026-03-01'); + expect(trend[0].positive).toBe(3); + expect(trend[0].negative).toBe(1); + }); + }); +}); diff --git a/web/src/components/dashboard/ai-feedback-stats.tsx b/web/src/components/dashboard/ai-feedback-stats.tsx new file mode 100644 index 00000000..6233825e --- /dev/null +++ b/web/src/components/dashboard/ai-feedback-stats.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { ThumbsDown, ThumbsUp } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +interface FeedbackStats { + positive: number; + negative: number; + total: number; + ratio: number | null; + trend: Array<{ + date: string; + positive: number; + negative: number; + }>; +} + +const PIE_COLORS = ['#22C55E', '#EF4444']; + +/** + * AI Feedback Stats dashboard card. + * Shows πŸ‘/πŸ‘Ž aggregate counts, approval ratio, and daily trend. + */ +export function AiFeedbackStats() { + const { selectedGuild, apiBase } = useGuildSelection(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchStats = useCallback(async () => { + if (!selectedGuild || !apiBase) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch(`${apiBase}/guilds/${selectedGuild.id}/ai-feedback/stats?days=30`, { + credentials: 'include', + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const data = (await res.json()) as FeedbackStats; + setStats(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load feedback stats'); + } finally { + setLoading(false); + } + }, [selectedGuild, apiBase]); + + useEffect(() => { + void fetchStats(); + }, [fetchStats]); + + if (!selectedGuild) return null; + + const pieData = + stats && stats.total > 0 + ? [ + { name: 'πŸ‘ Positive', value: stats.positive }, + { name: 'πŸ‘Ž Negative', value: stats.negative }, + ] + : []; + + return ( + + + + + AI Response Feedback + + + User πŸ‘/πŸ‘Ž reactions on AI-generated messages (last 30 days) + + + + {loading &&

Loading…

} + {error &&

{error}

} + {!loading && !error && stats && ( +
+ {/* Summary row */} +
+
+
+ + {stats.positive} +
+

Positive

+
+
+
+ + {stats.negative} +
+

Negative

+
+
+
+ {stats.ratio !== null ? `${stats.ratio}%` : 'β€”'} +
+

Approval Rate

+
+
+ + {stats.total === 0 && ( +

+ No feedback yet. Enable ai.feedback.enabled in + config to start collecting reactions. +

+ )} + + {pieData.length > 0 && ( +
+ {/* Pie chart */} +
+

Overall Split

+ + + `${name} ${(percent * 100).toFixed(0)}%`} + labelLine={false} + > + {pieData.map((_, index) => ( + + ))} + + + + +
+ + {/* Bar chart trend */} + {stats.trend.length > 0 && ( +
+

Daily Trend

+ + + + v.slice(5)} + /> + + + + + + + +
+ )} +
+ )} +
+ )} +
+
+ ); +}