Skip to content

Commit d29983e

Browse files
authored
refactor(chatbot): modularize assistant engine and harden moderation … (#828)
* refactor(chatbot): modularize assistant engine and harden moderation flow * chore(typos): exclude additional chatbot components from typo checks * fix(api): remove error detail leakage from chatbot route * feat(chatbot): enhance abuse tracking and CORS handling * feat(server): improve CORS handling and enhance remote response formatting * fix(chatbot): correct response formatting and update user role in AI client
1 parent 9652d58 commit d29983e

20 files changed

Lines changed: 2636 additions & 1748 deletions

SortVision/.env.example

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,23 @@ NEXT_PUBLIC_ENABLE_API_LOGGING=true
3535
# ===========================================
3636
# Chatbot Configuration
3737
# ===========================================
38-
# Gemini API key for AI chatbot functionality
39-
# Get your key from: https://makersuite.google.com/app/apikey
40-
NEXT_PUBLIC_GEMINI_API_KEY=your_gemini_api_key_here
41-
42-
# Gemini API endpoint (optional, defaults to /api/gemini)
43-
# NEXT_PUBLIC_GEMINI_ENDPOINT=/api/gemini
38+
# Chat provider: local (free) or nvidia (remote)
39+
NEXT_PUBLIC_CHAT_PROVIDER=local
40+
41+
# NVIDIA OpenAI-compatible API key (required only for remote AI mode)
42+
NVIDIA_API_KEY=your_nvidia_api_key_here
43+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
44+
NVIDIA_MODEL=moonshotai/kimi-k2-instruct
45+
NVIDIA_MODEL_FALLBACKS=
46+
NVIDIA_TIMEOUT_MS=15000
47+
NVIDIA_TEMPERATURE=0.6
48+
NVIDIA_TOP_P=0.9
49+
NVIDIA_MAX_TOKENS=1024
50+
51+
# Chat moderation and CORS controls
52+
NEXT_PUBLIC_CHAT_ABUSE_THRESHOLD=3
53+
CHAT_ABUSE_THRESHOLD=3
54+
CHAT_ABUSE_WINDOW_MS=600000
55+
CHAT_ABUSE_BLOCK_MS=86400000
56+
CHAT_ABUSE_TRACKER_MAX_SIZE=5000
57+
CORS_ALLOWED_ORIGINS=

SortVision/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@types/react-dom": "^19.2.3",
4242
"@vercel/analytics": "^2.0.1",
4343
"@vercel/speed-insights": "^2.0.0",
44+
"bad-words": "^4.0.0",
4445
"braces": "^3.0.3",
4546
"canvas-confetti": "^1.9.4",
4647
"class-variance-authority": "^0.7.1",
@@ -59,6 +60,7 @@
5960
"node-fetch": "^3.3.2",
6061
"node-html-parser": "^7.1.0",
6162
"on-headers": "^1.1.0",
63+
"openai": "^6.34.0",
6264
"postcss": "^8.5.9",
6365
"react": "^19.2.5",
6466
"react-dom": "^19.2.5",

SortVision/pnpm-lock.yaml

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

SortVision/server/index.js

Lines changed: 170 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// server/index.js
21
import express from 'express';
32
import cors from 'cors';
43
import dotenv from 'dotenv';
5-
import fetch from 'node-fetch'; // For backend API call
4+
import fetch from 'node-fetch';
65
import { fileURLToPath } from 'url';
76
import { dirname, join } from 'path';
7+
import { isAbusiveQuery } from '../src/components/chatbot/assistantEngine/moderation.js';
88

99
// Get the directory path of the current module
1010
const __filename = fileURLToPath(import.meta.url);
@@ -14,56 +14,198 @@ const __dirname = dirname(__filename);
1414
dotenv.config({ path: join(__dirname, '../.env') });
1515

1616
const app = express();
17-
app.use(cors());
17+
const CORS_ALLOWED_ORIGINS = (process.env.CORS_ALLOWED_ORIGINS || '')
18+
.split(',')
19+
.map(origin => origin.trim())
20+
.filter(Boolean);
21+
const IS_DEVELOPMENT = process.env.NODE_ENV === 'development';
22+
23+
app.use(
24+
cors({
25+
origin: (origin, callback) => {
26+
if (!origin) return callback(null, true);
27+
if (CORS_ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
28+
if (IS_DEVELOPMENT && CORS_ALLOWED_ORIGINS.length === 0) {
29+
return callback(null, true);
30+
}
31+
return callback(null, false);
32+
},
33+
})
34+
);
1835
app.use(express.json());
1936

20-
app.post('/api/gemini', async (req, res) => {
21-
console.log('📥 Received:', req.body);
37+
const ABUSE_THRESHOLD = Number(process.env.CHAT_ABUSE_THRESHOLD || 3);
38+
const ABUSE_WINDOW_MS = Number(
39+
process.env.CHAT_ABUSE_WINDOW_MS || 10 * 60 * 1000
40+
);
41+
const ABUSE_BLOCK_MS = Number(
42+
process.env.CHAT_ABUSE_BLOCK_MS || 24 * 60 * 60 * 1000
43+
);
44+
const ABUSE_TRACKER_MAX_SIZE = Number(
45+
process.env.CHAT_ABUSE_TRACKER_MAX_SIZE || 5000
46+
);
47+
const abuseTracker = new Map();
48+
49+
const getClientKey = req => {
50+
const forwardedFor = req.headers['x-forwarded-for'];
51+
const ip = (
52+
Array.isArray(forwardedFor)
53+
? forwardedFor[0]
54+
: forwardedFor || req.ip || 'unknown-ip'
55+
)
56+
.toString()
57+
.split(',')[0]
58+
.trim();
59+
const userAgent = (req.headers['user-agent'] || 'unknown-agent').toString();
60+
return `${ip}::${userAgent.slice(0, 120)}`;
61+
};
62+
63+
const latestUserMessage = messages => {
64+
for (let i = messages.length - 1; i >= 0; i -= 1) {
65+
const message = messages[i];
66+
const role = message?.role;
67+
if (role === 'user') {
68+
const content = Array.isArray(message?.parts)
69+
? message.parts.map(part => part?.text || '').join('\n')
70+
: message?.content || '';
71+
return String(content);
72+
}
73+
}
74+
return '';
75+
};
76+
77+
const pruneAbuseTrackerIfNeeded = now => {
78+
if (abuseTracker.size <= ABUSE_TRACKER_MAX_SIZE) return;
79+
for (const [key, record] of abuseTracker.entries()) {
80+
if (
81+
record.blockedUntil <= now &&
82+
record.lastAbuseAt > 0 &&
83+
now - record.lastAbuseAt > ABUSE_WINDOW_MS
84+
) {
85+
abuseTracker.delete(key);
86+
}
87+
if (abuseTracker.size <= ABUSE_TRACKER_MAX_SIZE) return;
88+
}
89+
};
90+
91+
app.post('/api/chatbot', async (req, res) => {
2292
const messages = req.body.messages;
93+
if (process.env.NODE_ENV === 'development') {
94+
console.log('Received chat request metadata:', {
95+
hasMessagesArray: Array.isArray(messages),
96+
messageCount: Array.isArray(messages) ? messages.length : 0,
97+
});
98+
}
2399

24-
// ✅ Check if messages is a valid array
25100
if (!Array.isArray(messages)) {
26101
return res
27102
.status(400)
28103
.json({ error: 'Expected prompt to be an array of messages' });
29104
}
30105

106+
const clientKey = getClientKey(req);
107+
const now = Date.now();
108+
const record = abuseTracker.get(clientKey) || {
109+
abuseCount: 0,
110+
lastAbuseAt: 0,
111+
blockedUntil: 0,
112+
};
113+
114+
if (record.blockedUntil > now) {
115+
return res.status(403).json({
116+
error:
117+
'Chat access temporarily restricted due to repeated policy violations',
118+
policy: 'abuse_block',
119+
retryAfterMs: record.blockedUntil - now,
120+
strikes: record.abuseCount,
121+
forceLocalOnly: true,
122+
});
123+
}
124+
125+
const latestQuery = latestUserMessage(messages);
126+
if (isAbusiveQuery(latestQuery)) {
127+
if (record.lastAbuseAt > 0 && now - record.lastAbuseAt > ABUSE_WINDOW_MS) {
128+
record.abuseCount = 0;
129+
}
130+
record.abuseCount += 1;
131+
record.lastAbuseAt = now;
132+
if (record.abuseCount >= ABUSE_THRESHOLD) {
133+
record.blockedUntil = now + ABUSE_BLOCK_MS;
134+
}
135+
abuseTracker.set(clientKey, record);
136+
pruneAbuseTrackerIfNeeded(now);
137+
138+
if (record.blockedUntil > now) {
139+
return res.status(403).json({
140+
error:
141+
'Chat access temporarily restricted due to repeated policy violations',
142+
policy: 'abuse_block',
143+
retryAfterMs: record.blockedUntil - now,
144+
strikes: record.abuseCount,
145+
forceLocalOnly: true,
146+
});
147+
}
148+
} else if (
149+
record.lastAbuseAt > 0 &&
150+
now - record.lastAbuseAt > ABUSE_WINDOW_MS &&
151+
record.blockedUntil <= now
152+
) {
153+
abuseTracker.delete(clientKey);
154+
}
155+
31156
try {
32-
// Optional: Debug log to verify request body
33-
console.log(
34-
'🟢 Gemini Request Body:',
35-
JSON.stringify({ contents: messages }, null, 2)
36-
);
37-
38-
const result = await fetch(
39-
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${process.env.NEXT_PUBLIC_GEMINI_API_KEY}`,
40-
{
41-
method: 'POST',
42-
headers: { 'Content-Type': 'application/json' },
43-
body: JSON.stringify({
44-
contents: messages, // ✅ Must match Gemini format exactly
45-
}),
46-
}
47-
);
157+
const apiKey = process.env.NVIDIA_API_KEY;
158+
const baseUrl =
159+
process.env.NVIDIA_BASE_URL || 'https://integrate.api.nvidia.com/v1';
160+
const model = process.env.NVIDIA_MODEL || 'moonshotai/kimi-k2-instruct';
161+
162+
if (!apiKey) {
163+
return res
164+
.status(500)
165+
.json({ error: 'NVIDIA_API_KEY is not configured' });
166+
}
167+
168+
const result = await fetch(`${baseUrl}/chat/completions`, {
169+
method: 'POST',
170+
headers: {
171+
'Content-Type': 'application/json',
172+
Authorization: `Bearer ${apiKey}`,
173+
},
174+
body: JSON.stringify({
175+
model,
176+
messages: messages.map(message => ({
177+
role:
178+
message?.role === 'model'
179+
? 'assistant'
180+
: ['user', 'assistant', 'system'].includes(message?.role)
181+
? message.role
182+
: 'user',
183+
content: Array.isArray(message?.parts)
184+
? message.parts.map(part => part?.text || '').join('\n')
185+
: message?.content || '',
186+
})),
187+
temperature: 0.6,
188+
top_p: 0.9,
189+
max_tokens: 1024,
190+
}),
191+
});
48192

49-
// Check if Gemini API returned a valid response
50193
if (!result.ok) {
51194
const errorText = await result.text();
52-
console.error('❌ Gemini API error:', errorText);
195+
console.error('Chat API error:', errorText);
53196
return res.status(result.status).json({ error: errorText });
54197
}
55198

56199
const data = await result.json();
57-
const text =
58-
data?.candidates?.[0]?.content?.parts?.[0]?.text || 'No response';
200+
const text = data?.choices?.[0]?.message?.content || 'No response';
59201
res.status(200).json({ text });
60202
} catch (error) {
61-
console.error('Server Error:', error);
203+
console.error('Server error:', error);
62204
res.status(500).json({ error: error.message });
63205
}
64206
});
65207

66208
const PORT = process.env.PORT || 3001;
67209
app.listen(PORT, () => {
68-
console.log(`✅ Gemini proxy server running on port ${PORT}`);
210+
console.log(`Chatbot proxy server running on port ${PORT}`);
69211
});

0 commit comments

Comments
 (0)