diff --git a/backend/config/queue.js b/backend/config/queue.js new file mode 100644 index 0000000..354782f --- /dev/null +++ b/backend/config/queue.js @@ -0,0 +1,32 @@ +const { Queue } = require("bullmq"); +const Redis = require("ioredis"); + +// Initialize Redis connection +const redisOptions = { + maxRetriesPerRequest: null, +}; + +const redisConnection = new Redis(process.env.REDIS_URL || "redis://localhost:6379", redisOptions); + +redisConnection.on("error", (err) => { + console.error("Redis connection error:", err); +}); + +// Initialize the queue +const aiQueue = new Queue("ai-jobs", { + connection: redisConnection, + defaultJobOptions: { + attempts: 3, + backoff: { + type: "exponential", + delay: 1000, + }, + removeOnComplete: true, + removeOnFail: false, + }, +}); + +module.exports = { + redisConnection, + aiQueue, +}; diff --git a/backend/controllers/aiController.js b/backend/controllers/aiController.js index 9807743..17a1ff1 100644 --- a/backend/controllers/aiController.js +++ b/backend/controllers/aiController.js @@ -8,7 +8,7 @@ const { const Session = require("../models/Session"); const Question = require("../models/Question"); -const ai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); +const { aiQueue } = require("../config/queue"); /** * Generate interview questions and answers using the Gemini AI service. @@ -67,73 +67,22 @@ const generateInterviewQuestions = async (req, res) => { seenQuestions, }); - const candidateModels = [ - process.env.GEMINI_MODEL, - "models/gemini-2.5-flash", - "models/gemini-flash-latest", - "models/gemini-2.0-flash", - ].filter(Boolean); - - let lastErr = null; - let result = null; - let usedModel = null; - - for (const m of candidateModels) { - try { - console.log(`Trying model: ${m}`); - const model = ai.getGenerativeModel({ model: m }); - result = await model.generateContent([prompt]); - usedModel = m; - console.log(`Successfully used model: ${m}`); - break; - } catch (e) { - console.error(`Model ${m} failed:`, e.message); - lastErr = e; - continue; - } - } - - if (!result) throw lastErr || new Error("All Gemini models failed"); - - const rawText = await result.response.text(); - let cleanedText = rawText - .replace(/^(\s*```json\s*|\s*```\s*)+/i, "") - .replace(/(\s*```\s*)+$/i, "") - .trim(); - - try { - const data = JSON.parse(cleanedText); - - // Validate Gemini response structure - const questionsSchema = Joi.array().items( - Joi.object({ - question: Joi.string().required(), - answer: Joi.string().required(), - }) - ); - const { error: validationError } = questionsSchema.validate( - Array.isArray(data) ? data : data.question - ); - if (validationError) { - return res.status(500).json({ message: "Invalid AI response format", details: validationError.message }); - } + const job = await aiQueue.add("generate-questions", { + role, + experience, + topicsToFocus, + numberOfQuestions, + seenQuestions, + }); - if (Array.isArray(data)) { - res.status(200).json({ model: usedModel, question: data }); - } else { - res.status(200).json({ model: usedModel, ...data }); - } - } catch (err) { - console.error("Gemini returned invalid JSON:", cleanedText); - res.status(500).json({ - message: "Gemini returned invalid JSON", - raw: rawText, - }); - } + res.status(202).json({ + message: "Generate questions job accepted", + jobId: job.id, + }); } catch (error) { - console.error("Gemini API Error:", error); + console.error("Gemini Queue API Error:", error); res.status(500).json({ - message: "Failed to generate questions", + message: "Failed to enqueue generate-questions job", error: error.message, }); } @@ -165,65 +114,18 @@ const generateConceptExplanation = async (req, res) => { return res.status(400).json({ message: "Missing question" }); } - const prompt = conceptExplainPrompt(question); - - const candidateModels = [ - process.env.GEMINI_MODEL, - "models/gemini-2.5-flash", - "models/gemini-flash-latest", - "models/gemini-2.0-flash", - ].filter(Boolean); - let lastErr = null; - let result = null; - let usedModel = null; - for (const m of candidateModels) { - try { - console.log(`Trying model: ${m}`); - const model = ai.getGenerativeModel({ model: m }); - result = await model.generateContent([prompt]); - usedModel = m; - console.log(`Successfully used model: ${m}`); - break; - } catch (e) { - console.error(`Model ${m} failed:`, e.message); - lastErr = e; - continue; - } - } - if (!result) throw lastErr || new Error("All Gemini models failed"); - - const rawText = await result.response.text(); - // Clean: remove all leading/trailing code block markers (```json, ```), even if repeated, and trim - let cleanedText = rawText - .replace(/^\s*```json\s*/i, "") - .replace(/^\s*```\s*/i, "") - .replace(/(\s*```\s*)+$/i, "") - .trim(); - - try { - const data = JSON.parse(cleanedText); - - // Validate Gemini response structure - const explanationSchema = Joi.object({ - title: Joi.string().required(), - explanation: Joi.string().required(), - }); - const { error: validationError } = explanationSchema.validate(data); - if (validationError) { - return res.status(500).json({ message: "Invalid AI response format", details: validationError.message }); - } + const job = await aiQueue.add("generate-explanation", { + question, + }); - res.status(200).json({ model: usedModel, ...data }); - } catch (err) { - res.status(500).json({ - message: "Gemini returned invalid JSON", - raw: rawText, - }); - } + res.status(202).json({ + message: "Generate explanation job accepted", + jobId: job.id, + }); } catch (error) { - console.error("Gemini API Error:", error); + console.error("Gemini Queue API Error:", error); res.status(500).json({ - message: "Failed to generate explanation", + message: "Failed to enqueue generate-explanation job", error: error.message, }); } @@ -237,56 +139,19 @@ const generateInterviewTips = async (req, res) => { return res.status(400).json({ message: "Missing required fields" }); } - const prompt = interviewTipsPrompt({ role, experience }); - - const candidateModels = [ - process.env.GEMINI_MODEL, - "models/gemini-2.5-flash", - "models/gemini-flash-latest", - "models/gemini-2.0-flash", - ].filter(Boolean); - - let lastErr = null; - let result = null; - let usedModel = null; - - for (const m of candidateModels) { - try { - console.log(`Trying model: ${m}`); - const model = ai.getGenerativeModel({ model: m }); - result = await model.generateContent([prompt]); - usedModel = m; - console.log(`Successfully used model: ${m}`); - break; - } catch (e) { - console.error(`Model ${m} failed:`, e.message); - lastErr = e; - continue; - } - } - - if (!result) throw lastErr || new Error("All Gemini models failed"); - - const rawText = await result.response.text(); - let cleanedText = rawText - .replace(/^(\s*```json\s*|\s*```\s*)+/i, "") - .replace(/(\s*```\s*)+$/i, "") - .trim(); + const job = await aiQueue.add("generate-tips", { + role, + experience, + }); - try { - const data = JSON.parse(cleanedText); - res.status(200).json({ model: usedModel, ...data }); - } catch (err) { - console.error("Gemini returned invalid JSON:", cleanedText); - res.status(500).json({ - message: "Gemini returned invalid JSON", - raw: rawText, - }); - } + res.status(202).json({ + message: "Generate tips job accepted", + jobId: job.id, + }); } catch (error) { - console.error("Gemini API Error:", error); + console.error("Gemini Queue API Error:", error); res.status(500).json({ - message: "Failed to generate interview tips", + message: "Failed to enqueue generate-tips job", error: error.message, }); } diff --git a/backend/package-lock.json b/backend/package-lock.json index 496a27b..5adce0c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,11 +13,13 @@ "@google/generative-ai": "^0.21.0", "axios": "^1.13.6", "bcryptjs": "^3.0.2", + "bullmq": "^5.79.2", "cors": "^2.8.5", "dotenv": "^17.2.1", "express": "^5.1.0", "express-rate-limit": "^8.2.1", "form-data": "^4.0.5", + "ioredis": "^5.11.1", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.18.0", @@ -144,6 +146,12 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -160,6 +168,84 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", @@ -1053,6 +1139,61 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.79.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz", + "integrity": "sha512-FebD+8XCZl/hnS1R4to24L4EAN70XSndKZO0776M36vGRk5MKVhKlNFM8/34zXLXKyYB4QaeIPFhVXSYYGTHpQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1146,6 +1287,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1239,6 +1389,18 @@ "node": ">= 0.10" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1265,6 +1427,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1278,7 +1449,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1864,6 +2035,28 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -2319,12 +2512,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -2361,6 +2566,15 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2580,6 +2794,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/multer": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", @@ -2670,6 +2915,12 @@ "node": ">= 0.6" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-cache": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", @@ -2702,6 +2953,21 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", @@ -3023,6 +3289,27 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/resend": { "version": "6.12.4", "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", @@ -3121,9 +3408,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3299,6 +3586,12 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -3472,9 +3765,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-is": { "version": "2.0.1", diff --git a/backend/package.json b/backend/package.json index edea614..f84cfb9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,11 +16,13 @@ "@google/generative-ai": "^0.21.0", "axios": "^1.13.6", "bcryptjs": "^3.0.2", + "bullmq": "^5.79.2", "cors": "^2.8.5", "dotenv": "^17.2.1", "express": "^5.1.0", "express-rate-limit": "^8.2.1", "form-data": "^4.0.5", + "ioredis": "^5.11.1", "joi": "^18.0.2", "jsonwebtoken": "^9.0.2", "mongoose": "^8.18.0", diff --git a/backend/routes/aiRoutes.js b/backend/routes/aiRoutes.js index feb95cc..8feda5f 100644 --- a/backend/routes/aiRoutes.js +++ b/backend/routes/aiRoutes.js @@ -6,6 +6,7 @@ const { validateAiPrompt } = require('../middlewares/validateAiPrompt'); const sanitizeAiPrompt = require('../middlewares/sanitizeAiPrompt'); const { isPrepPilotDomain, isContextualResponse } = require('../utils/domainClassifier'); const NodeCache = require('node-cache'); +const { aiQueue } = require('../config/queue'); // Cache to track off-topic attempts per IP (TTL: 1 hour) const offTopicCache = new NodeCache({ stdTTL: 3600 }); @@ -57,77 +58,23 @@ async function generateHandler(req, res) { .json({ error: "GEMINI_API_KEY not configured on server" }); } try { - const start = Date.now(); - const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); - const candidateModels = [ - process.env.GEMINI_MODEL, - "models/gemini-2.5-flash", - "models/gemini-flash-latest", - "models/gemini-2.0-flash", - ].filter(Boolean); - - let lastErr = null; - let result = null; - let usedModel = null; - for (const m of candidateModels) { - try { - const model = genAI.getGenerativeModel({ - model: m, - systemInstruction: systemInstruction || `You are PrepPilot AI Mentor. -1. Allow friendly greetings and casual onboarding conversation. -2. Focus primarily on PrepPilot-related domains: interview preparation, coding interviews, aptitude, resumes, career guidance, mock interviews, and platform usage. -3. Politely redirect unrelated conversations. -4. End your responses with a helpful, contextual follow-up question whenever appropriate (e.g., asking if they want an example, feedback on a resume section, or practice questions).` - }); - - // Format history for Gemini API - let formattedHistory = history.map(msg => ({ - role: msg.role === "model" ? "model" : "user", - parts: [{ text: msg.text }] - })); - - // Gemini requires the first message in history to be from the user - if (formattedHistory.length > 0 && formattedHistory[0].role !== "user") { - formattedHistory.unshift({ role: "user", parts: [{ text: "Hi" }] }); - } - - const chat = model.startChat({ - history: formattedHistory - }); - - result = await chat.sendMessage(prompt); - usedModel = m; - break; - } catch (e) { - lastErr = e; - continue; - } - } - if (!result) throw lastErr || new Error("All Gemini models failed"); - - const rawText = await result.response.text(); - console.log("Incoming Prompt:", prompt); - console.log("Model Used:", usedModel); - console.log("Raw Gemini Response:", rawText); - - let cleanedText = rawText - .replace(/^[\s`]*json\s*/i, "") - .replace(/^\s*```/i, "") - .replace(/```$/i, "") - .trim(); + // Add job to the queue + const job = await aiQueue.add("chat", { + prompt, + history, + systemInstruction + }); - console.log( - "[AI] promptLen=%d model=%s ms=%d", - prompt.length, - usedModel, - Date.now() - start, - ); - return res.json({ text: cleanedText, model: usedModel }); + // Return the jobId immediately + return res.status(202).json({ + message: "Chat generation job accepted", + jobId: job.id + }); } catch (error) { - console.error("[AI] Generation failed:", error.message); + console.error("[AI] Enqueuing chat job failed:", error.message); return res .status(500) - .json({ error: "Failed to generate content", detail: error.message }); + .json({ error: "Failed to enqueue chat job", detail: error.message }); } } diff --git a/backend/routes/jobRoutes.js b/backend/routes/jobRoutes.js new file mode 100644 index 0000000..97cd673 --- /dev/null +++ b/backend/routes/jobRoutes.js @@ -0,0 +1,50 @@ +const express = require("express"); +const router = express.Router(); +const { aiQueue } = require("../config/queue"); +const { generalLimiter } = require("../middlewares/rateLimiter"); +const { protect } = require("../middlewares/authMiddleware"); // Optional, but usually good + +/** + * Get job status and result. + * @route GET /api/jobs/:jobId + */ +router.get("/:jobId", generalLimiter, async (req, res) => { + try { + const { jobId } = req.params; + const job = await aiQueue.getJob(jobId); + + if (!job) { + return res.status(404).json({ message: "Job not found" }); + } + + const state = await job.getState(); + + if (state === "completed") { + return res.status(200).json({ + state, + result: job.returnvalue, + }); + } + + if (state === "failed") { + return res.status(500).json({ + state, + message: job.failedReason, + }); + } + + // waiting, active, delayed, etc. + return res.status(200).json({ + state, + message: `Job is currently ${state}`, + }); + } catch (error) { + console.error("Error fetching job status:", error); + res.status(500).json({ + message: "Failed to fetch job status", + error: error.message, + }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 2c287c5..7d95f02 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,8 @@ const questionRoutes = require("./routes/questionRoutes"); const aiRoutes = require("./routes/aiRoutes"); const resumeRoutes = require("./routes/resumeRoutes"); const aptitudeQuestionsRoutes = require("./routes/AptitudeQuestions.js"); +const jobRoutes = require("./routes/jobRoutes"); +require("./workers/aiWorker"); // Initialize the background worker const { generalLimiter, aiLimiter } = require("./middlewares/rateLimiter"); const { generalHeaders, sensitiveRouteHeaders } = require("./middlewares/securityHeaders"); // Remove ES Module import for cors. Use CommonJS require below. @@ -128,6 +130,8 @@ app.use( generateInterviewTips, ); +app.use("/api/jobs", jobRoutes); + app.use("/api/books", generalLimiter, booksRoutes); //Serve uploads folder diff --git a/backend/workers/aiWorker.js b/backend/workers/aiWorker.js new file mode 100644 index 0000000..ef47a34 --- /dev/null +++ b/backend/workers/aiWorker.js @@ -0,0 +1,227 @@ +const { Worker } = require("bullmq"); +const { GoogleGenerativeAI } = require("@google/generative-ai"); +const Joi = require("joi"); +const { redisConnection } = require("../config/queue"); +const { + conceptExplainPrompt, + questionAnswerPrompt, + interviewTipsPrompt, +} = require("../utils/prompts"); + +const ai = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + +const candidateModels = [ + process.env.GEMINI_MODEL, + "models/gemini-2.5-flash", + "models/gemini-flash-latest", + "models/gemini-2.0-flash", +].filter(Boolean); + +const processQuestions = async (data) => { + const { role, experience, topicsToFocus, numberOfQuestions, seenQuestions } = data; + const prompt = questionAnswerPrompt({ + role, + experience, + topicsToFocus, + numberOfQuestions, + seenQuestions, + }); + + let lastErr = null; + let result = null; + let usedModel = null; + + for (const m of candidateModels) { + try { + const model = ai.getGenerativeModel({ model: m }); + result = await model.generateContent([prompt]); + usedModel = m; + break; + } catch (e) { + lastErr = e; + continue; + } + } + + if (!result) throw lastErr || new Error("All Gemini models failed"); + + const rawText = await result.response.text(); + let cleanedText = rawText + .replace(/^(\s*```json\s*|\s*```\s*)+/i, "") + .replace(/(\s*```\s*)+$/i, "") + .trim(); + + const parsedData = JSON.parse(cleanedText); + + const questionsSchema = Joi.array().items( + Joi.object({ + question: Joi.string().required(), + answer: Joi.string().required(), + }) + ); + const { error: validationError } = questionsSchema.validate( + Array.isArray(parsedData) ? parsedData : parsedData.question + ); + if (validationError) { + throw new Error("Invalid AI response format: " + validationError.message); + } + + return { model: usedModel, question: Array.isArray(parsedData) ? parsedData : parsedData.question, raw: Array.isArray(parsedData) ? undefined : parsedData }; +}; + +const processExplanation = async (data) => { + const { question } = data; + const prompt = conceptExplainPrompt(question); + + let lastErr = null; + let result = null; + let usedModel = null; + for (const m of candidateModels) { + try { + const model = ai.getGenerativeModel({ model: m }); + result = await model.generateContent([prompt]); + usedModel = m; + break; + } catch (e) { + lastErr = e; + continue; + } + } + + if (!result) throw lastErr || new Error("All Gemini models failed"); + + const rawText = await result.response.text(); + let cleanedText = rawText + .replace(/^\s*```json\s*/i, "") + .replace(/^\s*```\s*/i, "") + .replace(/(\s*```\s*)+$/i, "") + .trim(); + + const parsedData = JSON.parse(cleanedText); + + const explanationSchema = Joi.object({ + title: Joi.string().required(), + explanation: Joi.string().required(), + }); + const { error: validationError } = explanationSchema.validate(parsedData); + if (validationError) { + throw new Error("Invalid AI response format: " + validationError.message); + } + + return { model: usedModel, ...parsedData }; +}; + +const processTips = async (data) => { + const { role, experience } = data; + const prompt = interviewTipsPrompt({ role, experience }); + + let lastErr = null; + let result = null; + let usedModel = null; + + for (const m of candidateModels) { + try { + const model = ai.getGenerativeModel({ model: m }); + result = await model.generateContent([prompt]); + usedModel = m; + break; + } catch (e) { + lastErr = e; + continue; + } + } + + if (!result) throw lastErr || new Error("All Gemini models failed"); + + const rawText = await result.response.text(); + let cleanedText = rawText + .replace(/^(\s*```json\s*|\s*```\s*)+/i, "") + .replace(/(\s*```\s*)+$/i, "") + .trim(); + + const parsedData = JSON.parse(cleanedText); + return { model: usedModel, ...parsedData }; +}; + +const processChat = async (data) => { + const { prompt, history, systemInstruction } = data; + + let lastErr = null; + let result = null; + let usedModel = null; + for (const m of candidateModels) { + try { + const model = ai.getGenerativeModel({ + model: m, + systemInstruction: systemInstruction || `You are PrepPilot AI Mentor. +1. Allow friendly greetings and casual onboarding conversation. +2. Focus primarily on PrepPilot-related domains: interview preparation, coding interviews, aptitude, resumes, career guidance, mock interviews, and platform usage. +3. Politely redirect unrelated conversations. +4. End your responses with a helpful, contextual follow-up question whenever appropriate (e.g., asking if they want an example, feedback on a resume section, or practice questions).` + }); + + let formattedHistory = (history || []).map(msg => ({ + role: msg.role === "model" ? "model" : "user", + parts: [{ text: msg.text }] + })); + + if (formattedHistory.length > 0 && formattedHistory[0].role !== "user") { + formattedHistory.unshift({ role: "user", parts: [{ text: "Hi" }] }); + } + + const chat = model.startChat({ + history: formattedHistory + }); + + result = await chat.sendMessage(prompt); + usedModel = m; + break; + } catch (e) { + lastErr = e; + continue; + } + } + if (!result) throw lastErr || new Error("All Gemini models failed"); + + const rawText = await result.response.text(); + let cleanedText = rawText + .replace(/^[\s`]*json\s*/i, "") + .replace(/^\s*```/i, "") + .replace(/```$/i, "") + .trim(); + + return { text: cleanedText, model: usedModel }; +}; + +const aiWorker = new Worker( + "ai-jobs", + async (job) => { + console.log(`Processing job ${job.id} of type ${job.name}`); + try { + if (job.name === "generate-questions") { + return await processQuestions(job.data); + } else if (job.name === "generate-explanation") { + return await processExplanation(job.data); + } else if (job.name === "generate-tips") { + return await processTips(job.data); + } else if (job.name === "chat") { + return await processChat(job.data); + } + throw new Error(`Unknown job type: ${job.name}`); + } catch (error) { + console.error(`Error processing job ${job.id}:`, error); + throw error; + } + }, + { connection: redisConnection } +); + +aiWorker.on("completed", (job) => { + console.log(`Job ${job.id} has completed!`); +}); + +aiWorker.on("failed", (job, err) => { + console.log(`Job ${job.id} has failed with ${err.message}`); +}); + +module.exports = aiWorker;