diff --git a/.env.example b/.env.example index 84102d4..4696c76 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ DATABASE_PORT=5432 DATABASE_NAME=your_database_name DATABASE_USER=your_database_user DATABASE_PASSWORD=your_database_password +LLM_API_KEY=your_llm_api_key diff --git a/database/schema.sql b/database/schema.sql index cd3b4b8..e4116e9 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -332,6 +332,16 @@ CREATE TABLE activity_logs ( display_metadata JSONB NOT NULL DEFAULT '{}' ); +CREATE TABLE chat_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organisation_id INTEGER REFERENCES organisations(id) ON DELETE SET NULL, + course_id INTEGER REFERENCES courses(id) ON DELETE SET NULL, + module_id INTEGER REFERENCES modules(id) ON DELETE SET NULL, + question TEXT NOT NULL, + answer TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); COMMIT; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b3c8582..2361cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.10.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", @@ -1228,6 +1229,23 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1651,6 +1669,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1823,6 +1853,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1960,6 +1999,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2162,6 +2216,63 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2364,6 +2475,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4061,6 +4187,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", diff --git a/package.json b/package.json index 6018c52..22764fd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "jest": "^29.7.0" }, "dependencies": { + "axios": "^1.10.0", "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", diff --git a/routes/chatbot.js b/routes/chatbot.js new file mode 100644 index 0000000..b21d7a8 --- /dev/null +++ b/routes/chatbot.js @@ -0,0 +1,248 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +const axios = require("axios"); +const GROQ_API_KEY = process.env.LLM_API_KEY; + +async function callLLM(context) { + const systemPrompt = ` + You are a technical course assistant for an online platform. + Use the course/module/skills context provided to answer user questions using your expertise, + as if you are an instructor on that module. Do not mention that you lack material access. + Do not mention "Since we are in this particular course", just answer the question directly. + Answer the question as if you are directly talking to the student. + If unsure, give your best expert guess based on course/module metadata and tags. Keep your answers concise and focused on the question. + If the question is not related to the course/module, politely redirect them to the appropriate support. Do not answer anything unrelated to the + course material at all. + `; + + const messages = [ + { role: "system", content: systemPrompt }, + { role: "user", content: JSON.stringify(context, null, 2) }, + { role: "user", content: context.question }, + ]; + + const resp = await axios.post( + "https://api.groq.com/openai/v1/chat/completions", + { + model: "llama3-70b-8192", + messages, + temperature: 0.3, + max_tokens: 512, + }, + { + headers: { + Authorization: `Bearer ${GROQ_API_KEY}`, + "Content-Type": "application/json", + }, + } + ); + + return resp.data.choices[0].message.content; +} + +router.post("/ask", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const userId = user.userId; + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(403).json({ message: "Forbidden" }); + } + + const { courseId, moduleId, question } = req.body; + if (!courseId || !moduleId) { + return res + .status(400) + .json({ message: "Course and module IDs are required" }); + } + if (!question || question.trim() === "") { + return res.status(400).json({ message: "Question is required" }); + } + + try { + const client = await pool.connect(); + const courseRes = await client.query( + `SELECT name, description + FROM courses + WHERE id = $1`, + [courseId] + ); + const course = courseRes.rows[0]; + const moduleRes = await client.query( + `SELECT title, description + FROM modules + WHERE id = $1`, + [moduleId] + ); + const module = moduleRes.rows[0]; + + const courseSkillsRes = await client.query( + `SELECT s.id, s.name, s.description + FROM module_skills ms + JOIN skills s ON s.id = ms.skill_id + WHERE ms.module_id = $1`, + [moduleId] + ); + const courseSkills = + courseSkillsRes.rows.map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + })) || []; + const channelRes = await client.query( + `SELECT c.id, c.name, c.description + FROM course_channels cc + JOIN channels c ON c.id = cc.channel_id + WHERE cc.course_id = $1`, + [courseId] + ); + const channel = channelRes.rows[0] || { + id: null, + name: "No channel", + description: "", + }; + + const levelRes = await client.query( + `SELECT l.id, l.name, l.description, l.sort_order + FROM course_channels cc + JOIN levels l ON l.id = cc.level_id + WHERE cc.course_id = $1`, + [courseId] + ); + const level = levelRes.rows[0] || { + id: null, + name: "No level", + description: "", + sort_order: 0, + }; + + const context = { + course_name: course.name, + course_description: course.description, + module_name: module.title, + module_description: module.description, + channel: { + id: channel.id, + name: channel.name, + description: channel.description, + }, + level: { + id: level.id, + name: level.name, + description: level.description, + sort_order: level.sort_order, + }, + skill_tags: courseSkills.map((s) => ({ + id: s.id, + name: s.name, + description: s.description, + })), + question: question, + }; + + const answer = await callLLM(context); + + await client.query( + `INSERT INTO chat_logs (user_id, organisation_id, course_id, module_id, question, answer) + VALUES ($1, $2, $3, $4, $5, $6)`, + [userId, organisationId, courseId, moduleId, question, answer] + ); + + await client.release(); + + return res.json({ success: true, answer }); + } catch (err) { + console.error("Error processing question:", err); + return res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/logs", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + let user; + try { + user = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session" }); + } + if (!user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const userId = user.userId; + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(403).json({ message: "Forbidden" }); + } + + const { courseId, moduleId } = req.body; + if (!courseId || !moduleId) { + return res + .status(400) + .json({ message: "Course and module IDs are required" }); + } + try { + const client = await pool.connect(); + const logs = await client.query( + `SELECT question, answer + FROM chat_logs + WHERE user_id = $1 AND organisation_id = $2 + AND course_id = $3 AND module_id = $4 + ORDER BY created_at DESC`, + [userId, organisationId, courseId, moduleId] + ); + await client.release(); + return res.json({ success: true, logs: logs.rows }); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}); + +router.get("/history", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const userId = user.userId; + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(403).json({ message: "Forbidden" }); + } + + try { + const client = await pool.connect(); + const logs = await client.query( + `SELECT cl.id, c.id as course_id, c.name, m.id as module_id, m.title, cl.question, cl.answer, cl.created_at + FROM chat_logs cl, courses c, modules m + WHERE cl.course_id = c.id and cl.module_id = m.id AND + cl.user_id = $1 AND cl.organisation_id = $2 + ORDER BY created_at DESC`, + [userId, organisationId] + ); + await client.release(); + return res.json({ success: true, logs: logs.rows }); + } catch (err) { + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 8dd8b42..d253229 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,7 @@ const roadmapRoutes = require("./routes/roadmaps"); const materialRoutes = require("./routes/materials"); const activityRoutes = require("./routes/activity"); const dashboardRoutes = require("./routes/dashboard"); +const chatbotRoutes = require("./routes/chatbot"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -54,6 +55,7 @@ app.use("/api/roadmaps", roadmapRoutes); app.use("/api/materials", materialRoutes); app.use("/api/activity", activityRoutes); app.use("/api/dashboard", dashboardRoutes); +app.use("/api/chatbot", chatbotRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);