Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions config.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
{
"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,
"threadMode": {
"enabled": false,
"autoArchiveMinutes": 60,
"reuseWindowMinutes": 30
},
"feedback": {
"enabled": false
}
},
"triage": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -213,19 +216,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"
}
]
},
Expand Down Expand Up @@ -271,4 +274,4 @@
"enabled": false,
"maxPerUser": 25
}
}
}
40 changes: 40 additions & 0 deletions migrations/003_ai_feedback.cjs
Original file line number Diff line number Diff line change
@@ -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 CHECK (feedback_type IN ('positive', 'negative')),
created_at TIMESTAMP 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\`);
};
4 changes: 4 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,6 +37,9 @@
// (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);
Expand Down
257 changes: 257 additions & 0 deletions src/api/routes/ai-feedback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/**
* 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 { 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) => {
const { dbPool } = req.app.locals;
if (!dbPool) {
return res.status(503).json({ error: 'Database not available' });
}

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;
}
}

try {
const [statsResult, trendResult] = await Promise.all([
dbPool.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],
),
dbPool.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() - ($2 * interval '1 day')
GROUP BY DATE(created_at)
ORDER BY date ASC`,
[guildId, days],
),
]);

const row = statsResult.rows[0];
const positive = row?.positive || 0;
const negative = row?.negative || 0;
const total = row?.total || 0;
const ratio = total > 0 ? Math.round((positive / total) * 100) : null;

res.json({
positive,
negative,
total,
ratio,
trend: trendResult.rows.map((r) => ({
date: r.date,
positive: r.positive,
negative: r.negative,
})),
});
} catch (err) {
logError('Failed to fetch AI feedback stats', { error: err.message, guild: guildId });
res.status(500).json({ error: 'Failed to fetch AI feedback stats' });
}
});

// ── 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) => {
const { dbPool } = req.app.locals;
if (!dbPool) {
return res.status(503).json({ error: 'Database not available' });
}

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;
}
}

try {
const result = await dbPool.query(
`SELECT id, message_id, channel_id, user_id, feedback_type, created_at
FROM ai_feedback
WHERE guild_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[guildId, limit],
);

res.json({
feedback: result.rows.map((r) => ({
id: r.id,
messageId: r.message_id,
channelId: r.channel_id,
userId: r.user_id,
feedbackType: r.feedback_type,
createdAt: r.created_at,
})),
});
} catch (err) {
logError('Failed to fetch recent AI feedback', { error: err.message, guild: guildId });
res.status(500).json({ error: 'Failed to fetch recent AI feedback' });
}
});

export default router;
Loading
Loading