diff --git a/backend/data/faqs.json b/backend/data/faqs.json new file mode 100644 index 0000000..2bec65f --- /dev/null +++ b/backend/data/faqs.json @@ -0,0 +1,14 @@ +[ + { + "q": "How do I reset my password?", + "a": "Click **Forgot Password** on the sign-in screen. Enter your e-mail and follow the link we send." + }, + { + "q": "Why can’t I see any careers in the list?", + "a": "Make sure you’ve finished the Interest Inventory and set at least one career-priority filter." + }, + { + "q": "What browsers are supported?", + "a": "Chrome, Edge, Safari, and Firefox (latest two versions). Mobile Safari is fully supported." + } +] diff --git a/backend/server2.js b/backend/server2.js index c02d699..0e85e34 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -14,6 +14,12 @@ import { open } from 'sqlite'; import sqlite3 from 'sqlite3'; import fs from 'fs'; import readline from 'readline'; +import chatFreeEndpoint from "./utils/chatFreeEndpoint.js"; +import { OpenAI } from 'openai'; +import rateLimit from 'express-rate-limit'; +import authenticateUser from './utils/authenticateUser.js'; +import { vectorSearch } from "./utils/vectorSearch.js"; + // --- Basic file init --- const __filename = fileURLToPath(import.meta.url); @@ -24,6 +30,14 @@ const env = process.env.NODE_ENV?.trim() || 'development'; const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath }); // Load .env +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + +const chatLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 20, + keyGenerator: req => req.user?.id || req.ip +}); + // Whitelist CORS const allowedOrigins = [ 'http://localhost:3000', @@ -883,6 +897,7 @@ app.post('/api/ai-risk', async (req, res) => { jobDescription, tasks, riskLevel, + vectorSearch, reasoning, }); @@ -893,6 +908,13 @@ app.post('/api/ai-risk', async (req, res) => { } }); +chatFreeEndpoint(app, { + openai, + authenticateUser, // or omit if you don’t need auth yet + chatLimiter, + userProfileDb +}); + /************************************************** * Start the Express server **************************************************/ diff --git a/backend/user_profile.db b/backend/user_profile.db new file mode 100644 index 0000000..e69de29 diff --git a/backend/utils/authenticateUser.js b/backend/utils/authenticateUser.js new file mode 100644 index 0000000..ab4e370 --- /dev/null +++ b/backend/utils/authenticateUser.js @@ -0,0 +1,19 @@ +import jwt from "jsonwebtoken"; +const SECRET_KEY = process.env.SECRET_KEY || "supersecurekey"; + +/** + * Adds `req.user = { id: }` + * If no or bad token ➜ 401. + */ +export default function authenticateUser(req, res, next) { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) return res.status(401).json({ error: "Authorization token required" }); + + try { + const { id } = jwt.verify(token, SECRET_KEY); + req.user = { id }; // attach the id for downstream use + next(); + } catch (err) { + return res.status(401).json({ error: "Invalid or expired token" }); + } +} diff --git a/backend/utils/chatFreeEndpoint.js b/backend/utils/chatFreeEndpoint.js new file mode 100644 index 0000000..30205fe --- /dev/null +++ b/backend/utils/chatFreeEndpoint.js @@ -0,0 +1,171 @@ +// utils/chatFreeEndpoint.js +import path from "path"; +import { fileURLToPath } from "url"; +import { vectorSearch } from "./vectorSearch.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootPath = path.resolve(__dirname, "..", ".."); // backend/ +const FAQ_PATH = path.join(rootPath, "user_profile.db"); +const FAQ_THRESHOLD = 0.80; +const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"]; + +/* ---------- helpers ---------- */ +const classifyIntent = txt => + HELP_TRIGGERS.some(k => txt.toLowerCase().includes(k)) ? "support" : "guide"; + +const buildContext = (user = {}, page = "", intent = "guide") => { + const { firstname = "User", career_situation = "planning" } = user; + const mode = intent === "guide" ? "Aptiva Guide" : "Aptiva Support"; + return { + system: + `${mode} for page “${page}”. User: ${firstname}. Situation: ${career_situation}.` + + (intent === "support" + ? " Resolve issues quickly and end with: “Let me know if that fixed it.”" + : "") + }; +}; + +/* ---------------------------------------------------------------------------- + FACTORY: registers POST /api/chat/free on the passed-in Express app +----------------------------------------------------------------------------- */ +export default function chatFreeEndpoint( + app, + { + openai, + chatLimiter, + userProfileDb, + authenticateUser = (_req, _res, next) => next() + } +) { + /* -------- support-intent tool handlers now in scope -------- */ + const toolResolvers = { + async clearLocalCache() { return { status: "ok" }; }, + + async openTicket({ summary = "", user = {} }) { + try { + await userProfileDb.run( + `INSERT INTO support_tickets (user_id, summary, created_at) + VALUES (?,?,datetime('now'))`, + [user.id || 0, summary] + ); + return { ticket: "created" }; + } catch (err) { + console.error("[openTicket] DB error:", err); + return { ticket: "error" }; + } + }, + + async pingStatus() { + try { + await userProfileDb.get("SELECT 1"); + return { status: "healthy" }; + } catch { + return { status: "db_error" }; + } + } + }; + + const SUPPORT_TOOLS = [ + { + type: "function", + function: { + name: "clearLocalCache", + description: "Instruct front-end to purge its cache", + parameters: { type: "object", properties: {} } + } + }, + { + type: "function", + function: { + name: "openTicket", + description: "Create a support ticket", + parameters: { + type: "object", + properties: { summary: { type: "string" } }, + required: ["summary"] + } + } + }, + { + type: "function", + function: { + name: "pingStatus", + description: "Ping DB/health", + parameters: { type: "object", properties: {} } + } + } +]; + + /* ----------------------------- ROUTE ----------------------------- */ + app.post( + "/api/chat/free", + chatLimiter, + authenticateUser, + async (req, res) => { + try { + const { prompt = "", chatHistory = [], pageContext = "" } = req.body || {}; + if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" }); + + /* ---------- 0️⃣ FAQ fast-path ---------- */ + let faqHit = null; + try { + const { data } = await openai.embeddings.create({ + model: "text-embedding-3-small", + input: prompt + }); + const hits = await vectorSearch(FAQ_PATH, data[0].embedding, 1); + if (hits.length && hits[0].score >= FAQ_THRESHOLD) faqHit = hits[0]; + } catch { /* silently ignore if table/function missing */ } + + if (faqHit) return res.json({ answer: faqHit.answer }); + /* --------------------------------------- */ + + const intent = classifyIntent(prompt); + const { system } = buildContext(req.user || {}, pageContext, intent); + const tools = intent === "support" ? SUPPORT_TOOLS : []; + + const messages = [ + { role:"system", content: system }, + ...chatHistory, + { role:"user", content: prompt } + ]; + + const chatStream = await openai.chat.completions.create({ + model : "gpt-4o-mini", + stream : true, + messages, + tools, + tool_choice : tools.length ? "auto" : undefined + }); + + /* ---------- SSE headers ---------- */ + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + + for await (const part of chatStream) { + const delta = part.choices?.[0]?.delta || {}; + const chunk = delta.content || ""; + if (chunk) res.write(chunk); // ← plain text, no “data:” prefix + } + res.end(); + + /* ---------- tool calls ---------- */ + chatStream.on("tool", async call => { + const fn = toolResolvers[call.name]; + if (!fn) return; + try { + const args = JSON.parse(call.arguments || "{}"); + await fn({ ...args, user: req.user || {} }); + } catch (err) { + console.error("[tool resolver]", err); + } + }); + } catch (err) { + console.error("/api/chat/free error:", err); + res.status(500).json({ error: "Internal server error" }); + } + } + ); +} diff --git a/backend/utils/seedFaq.js b/backend/utils/seedFaq.js new file mode 100644 index 0000000..d560617 --- /dev/null +++ b/backend/utils/seedFaq.js @@ -0,0 +1,36 @@ +import { readFileSync } from "fs"; +import { OpenAI } from "openai"; +import sqlite3 from "sqlite3"; +import { open } from "sqlite"; +import dotenv from "dotenv"; +import { fileURLToPath } from 'url'; +import path from 'path'; + +// --- Basic file init --- +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootPath = path.resolve(__dirname, "../.."); +const env = process.env.NODE_ENV?.trim() || "development"; +dotenv.config({ path: path.resolve(rootPath, `.env.${env}`) }); + +const faqPath = path.resolve(rootPath, "backend", "data", "faqs.json"); +const faqs = JSON.parse(readFileSync(faqPath, "utf8")); + +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + +const dbPath = path.resolve(rootPath, "user_profile.db"); +const db = await open({ filename: dbPath, driver: sqlite3.Database }); + +for (const { q, a } of faqs) { + const { data } = await openai.embeddings.create({ + model: "text-embedding-3-small", + input: q + }); + + const buf = Buffer.from(new Float32Array(data[0].embedding).buffer); + await db.run( + `INSERT INTO faq_embeddings (question,answer,embedding) VALUES (?,?,?)`, + [q, a, buf] + ); +} +console.log("Seeded FAQ embeddings"); diff --git a/backend/utils/vectorSearch.js b/backend/utils/vectorSearch.js new file mode 100644 index 0000000..f86983c --- /dev/null +++ b/backend/utils/vectorSearch.js @@ -0,0 +1,38 @@ +// utils/vectorSearch.js +import sqlite3 from "sqlite3"; +import { open } from "sqlite"; + +/* ---------- small helper ---------- */ +function cosineSim(a, b) { + let dot = 0, na = 0, nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0; +} + +/** + * JS-only vector search against faq_embeddings + * @param {string} dbPath absolute path to user_profile.db + * @param {number[]} queryEmbedding the embedding array from OpenAI + * @param {number} topK how many rows to return + */ +export async function vectorSearch(dbPath, queryEmbedding, topK = 3) { + const db = await open({ filename: dbPath, driver: sqlite3.Database }); + const rows = await db.all(`SELECT id, question, answer, embedding FROM faq_embeddings`); + await db.close(); + + const scored = rows.map(r => { + // SQLite returns Buffer → turn it back into Float32Array + const vec = new Float32Array(r.embedding.buffer, + r.embedding.byteOffset, + r.embedding.length / 4); + return { ...r, score: cosineSim(queryEmbedding, vec) }; + }) + .sort((a, b) => b.score - a.score) // highest similarity first + .slice(0, topK); + + return scored; +} diff --git a/package-lock.json b/package-lock.json index e6b51e9..65a977b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "cra-template": "1.2.0", "docx": "^9.5.0", "dotenv": "^16.4.7", + "express-rate-limit": "^7.5.1", "file-saver": "^2.0.5", "fuse.js": "^7.1.0", "helmet": "^8.0.0", @@ -9112,6 +9113,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", diff --git a/package.json b/package.json index b2b6e2e..a85fa65 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cra-template": "1.2.0", "docx": "^9.5.0", "dotenv": "^16.4.7", + "express-rate-limit": "^7.5.1", "file-saver": "^2.0.5", "fuse.js": "^7.1.0", "helmet": "^8.0.0", diff --git a/src/App.js b/src/App.js index a1c5e54..3e4f9ce 100644 --- a/src/App.js +++ b/src/App.js @@ -31,6 +31,7 @@ import OnboardingContainer from './components/PremiumOnboarding/OnboardingContai import RetirementPlanner from './components/RetirementPlanner.js'; import ResumeRewrite from './components/ResumeRewrite.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js'; +import usePageContext from './utils/usePageContext.js'; @@ -39,6 +40,7 @@ export const ProfileCtx = React.createContext(); function App() { const navigate = useNavigate(); const location = useLocation(); + usePageContext(); // Auth states const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index daabbdf..cd9349c 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -6,6 +6,7 @@ import CareerPrioritiesModal from './CareerPrioritiesModal.js'; import CareerModal from './CareerModal.js'; import InterestMeaningModal from './InterestMeaningModal.js'; import CareerSearch from './CareerSearch.js'; +import ChatDrawer from './ChatDrawer.js'; import { Button } from './ui/button.js'; import axios from 'axios'; @@ -784,6 +785,38 @@ const handleSelectForEducation = (career) => { ); }; + /* ---------- Chat-assistant snapshot ---------- */ +const explorerSnapshot = useMemo(() => { + // 1) RIASEC scores (if the interest inventory has been done) + const riasecScores = userProfile?.riasec_scores + ? JSON.parse(userProfile.riasec_scores) // stored as JSON in DB + : null; + + // 2) User-selected career-priority weights, normalised to 0-1 + const priorityWeights = priorities + ? { + stability : priorityWeight('stability' , priorities.stability) / 5, + growth : priorityWeight('growth' , priorities.growth) / 5, + balance : priorityWeight('balance' , priorities.balance) / 5, + recognition : priorityWeight('recognition', priorities.recognition)/ 5, + interests : priorityWeight('interests' , priorities.interests) / 5, + mission : priorityWeight('meaning' , priorities.meaning) / 5, + } + : null; + + // 3) Matrix of careers currently on-screen (or in the comparison list, pick one) + const careerMatrix = filteredCareers.map(c => ({ + socCode : c.code, + careerName : c.title, + // quick-and-dirty match score; replace with your own if you’ve already got one + matchScore : typeof c.matchScore === 'number' + ? c.matchScore + : (fitRatingMap[c.fit] || 0) * 20 // e.g. “Best” → 100, “Good” → 60 + })); + + return { riasecScores, priorityWeights, careerMatrix }; +}, [userProfile, priorities, filteredCareers]); + // ------------------------------------------------------ // Render // ------------------------------------------------------ @@ -984,6 +1017,11 @@ const handleSelectForEducation = (career) => { defaultMeaning={modalData.defaultMeaning} /> + + {selectedCareer && ( { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, [messages]); + + // ─────────────────────────────────────────────── helper: append chunk + function appendAssistantChunk(chunk) { + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last && last.role === "assistant") { + // mutate copy – React will re‑render because we return new array + const updated = [...prev]; + updated[updated.length - 1] = { + ...last, + content: last.content + chunk, + }; + return updated; + } + // first assistant chunk → push new msg + return [...prev, { role: "assistant", content: chunk }]; + }); + } + + // ───────────────────────────────────────────────────────── send prompt + async function sendPrompt() { + const text = prompt.trim(); + if (!text) return; + + // optimistic user message + setMessages((m) => [...m, { role: "user", content: text }]); + setPrompt(""); + + const token = localStorage.getItem("token") || ""; + try { + const resp = await fetch("/api/chat/free", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "text/event-stream", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + prompt: text, + pageContext, + chatHistory: messages, + snapshot, + }), + }); + + if (!resp.ok || !resp.body) { + throw new Error(`Request failed ${resp.status}`); + } + + // stream the response + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let done = false; + while (!done) { + const { value, done: doneReading } = await reader.read(); + done = doneReading; + if (value) { + const chunk = decoder.decode(value); + appendAssistantChunk(chunk); + } + } + } catch (err) { + console.error("[ChatDrawer] error", err); + appendAssistantChunk("Sorry – something went wrong. Try again later."); + } + } + + // ────────────────────────────────────────────────────── key handler + function handleKeyDown(e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendPrompt(); + } + } + + // ─────────────────────────────────────────────────────────── render + return ( + + {/* Floating action button */} + + + + + {/* Drawer */} + + {/* header */} +
+ {pageContext === "explorer" ? "Career Explorer Guide" : "Programs Guide"} +
+ + {/* messages list */} +
+ {messages.map((m, i) => ( +
+ {m.content} +
+ ))} +
+ + {/* input */} +
+
{ + e.preventDefault(); + sendPrompt(); + }} + className="flex gap-2" + > + setPrompt(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask me anything…" + className="flex-1" + /> + +
+
+
+
+ ); +} diff --git a/src/components/ui/sheet.js b/src/components/ui/sheet.js new file mode 100644 index 0000000..fea5e23 --- /dev/null +++ b/src/components/ui/sheet.js @@ -0,0 +1,92 @@ +import { createContext, useContext, useState } from "react"; +import { createPortal } from "react-dom"; + +/** + * Minimal implementation (slide‑in drawer) using plain React + Tailwind. + * + * Usage: + * + * + * + * + */ + +const SheetContext = createContext({ open: false, setOpen: () => {} }); + +export function Sheet({ open: controlled, onOpenChange, children }) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const isControlled = controlled !== undefined; + const open = isControlled ? controlled : uncontrolledOpen; + const setOpen = isControlled ? onOpenChange : setUncontrolledOpen; + + return ( + + {children} + + ); +} + +export function SheetTrigger({ asChild, children, ...rest }) { + const { setOpen } = useContext(SheetContext); + const trigger = ( + + ); + + return asChild ? children : trigger; +} + +const sheetRoot = (() => { + let root = document.getElementById("__sheet_root"); + if (!root) { + root = document.createElement("div"); + root.id = "__sheet_root"; + document.body.appendChild(root); + } + return root; +})(); + +export function SheetContent({ side = "right", className = "", children }) { + const { open, setOpen } = useContext(SheetContext); + if (!open) return null; + + const translateMap = { + right: "translate-x-full", + left: "-translate-x-full", + top: "-translate-y-full", + bottom: "translate-y-full", + }; + + const positionMap = { + right: "right-0 top-0 h-full w-80 md:w-96", + left: "left-0 top-0 h-full w-80 md:w-96", + top: "top-0 left-0 w-full h-1/2", + bottom: "bottom-0 left-0 w-full h-1/2", + }; + + return createPortal( +
+ {/* backdrop */} +
setOpen(false)} + /> + + {/* panel */} +
+ {children} +
+
, + sheetRoot + ); +} diff --git a/src/utils/usePageContext.js b/src/utils/usePageContext.js new file mode 100644 index 0000000..b4a123b --- /dev/null +++ b/src/utils/usePageContext.js @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { sendSystemMessage } from '../utils/chatApi.js'; // you already call this for Jess + +export default function usePageContext () { + const { pathname } = useLocation(); + + useEffect(() => { + const page = pathname.split('/')[1] || 'Home'; + const featureFlags = window.__APTIVA_FLAGS__ || []; + + // invisible system message for the adaptive assistant + sendSystemMessage('page_context', { page, featureFlags }); + }, [pathname]); +} diff --git a/tailwind.config.js b/tailwind.config.js index 267991d..776552c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,14 @@ // tailwind.config.js module.exports = { content: ['./src/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + keyframes: { + "slide-in": { from: { transform: "translateX(100%)" }, to: { transform: "translateX(0)" } }, + }, + animation: { "slide-in": "slide-in 0.25s ease-out forwards" }, + }, +}, theme: { extend: { /* brand colours */ diff --git a/user_profile.db b/user_profile.db index 1421d26..2fa80e7 100644 Binary files a/user_profile.db and b/user_profile.db differ