AI Support bot created.

This commit is contained in:
Josh 2025-07-02 11:36:14 +00:00
parent 4ca8d11156
commit 2160f20f93
16 changed files with 634 additions and 0 deletions

14
backend/data/faqs.json Normal file
View File

@ -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 cant I see any careers in the list?",
"a": "Make sure youve 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."
}
]

View File

@ -14,6 +14,12 @@ import { open } from 'sqlite';
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
import fs from 'fs'; import fs from 'fs';
import readline from 'readline'; 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 --- // --- Basic file init ---
const __filename = fileURLToPath(import.meta.url); 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}`); const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath }); // Load .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 // Whitelist CORS
const allowedOrigins = [ const allowedOrigins = [
'http://localhost:3000', 'http://localhost:3000',
@ -883,6 +897,7 @@ app.post('/api/ai-risk', async (req, res) => {
jobDescription, jobDescription,
tasks, tasks,
riskLevel, riskLevel,
vectorSearch,
reasoning, reasoning,
}); });
@ -893,6 +908,13 @@ app.post('/api/ai-risk', async (req, res) => {
} }
}); });
chatFreeEndpoint(app, {
openai,
authenticateUser, // or omit if you dont need auth yet
chatLimiter,
userProfileDb
});
/************************************************** /**************************************************
* Start the Express server * Start the Express server
**************************************************/ **************************************************/

0
backend/user_profile.db Normal file
View File

View File

@ -0,0 +1,19 @@
import jwt from "jsonwebtoken";
const SECRET_KEY = process.env.SECRET_KEY || "supersecurekey";
/**
* Adds `req.user = { id: <user_profile.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" });
}
}

View File

@ -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" });
}
}
);
}

36
backend/utils/seedFaq.js Normal file
View File

@ -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");

View File

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

16
package-lock.json generated
View File

@ -26,6 +26,7 @@
"cra-template": "1.2.0", "cra-template": "1.2.0",
"docx": "^9.5.0", "docx": "^9.5.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express-rate-limit": "^7.5.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
@ -9112,6 +9113,21 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express/node_modules/cookie": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",

View File

@ -21,6 +21,7 @@
"cra-template": "1.2.0", "cra-template": "1.2.0",
"docx": "^9.5.0", "docx": "^9.5.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express-rate-limit": "^7.5.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",

View File

@ -31,6 +31,7 @@ import OnboardingContainer from './components/PremiumOnboarding/OnboardingContai
import RetirementPlanner from './components/RetirementPlanner.js'; import RetirementPlanner from './components/RetirementPlanner.js';
import ResumeRewrite from './components/ResumeRewrite.js'; import ResumeRewrite from './components/ResumeRewrite.js';
import LoanRepaymentPage from './components/LoanRepaymentPage.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js';
import usePageContext from './utils/usePageContext.js';
@ -39,6 +40,7 @@ export const ProfileCtx = React.createContext();
function App() { function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
usePageContext();
// Auth states // Auth states
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);

View File

@ -6,6 +6,7 @@ import CareerPrioritiesModal from './CareerPrioritiesModal.js';
import CareerModal from './CareerModal.js'; import CareerModal from './CareerModal.js';
import InterestMeaningModal from './InterestMeaningModal.js'; import InterestMeaningModal from './InterestMeaningModal.js';
import CareerSearch from './CareerSearch.js'; import CareerSearch from './CareerSearch.js';
import ChatDrawer from './ChatDrawer.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import axios from 'axios'; 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 youve 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 // Render
// ------------------------------------------------------ // ------------------------------------------------------
@ -984,6 +1017,11 @@ const handleSelectForEducation = (career) => {
defaultMeaning={modalData.defaultMeaning} defaultMeaning={modalData.defaultMeaning}
/> />
<ChatDrawer
pageContext="explorer"
snapshot={explorerSnapshot} // the JSON youre already building
/>
{selectedCareer && ( {selectedCareer && (
<CareerModal <CareerModal
career={selectedCareer} career={selectedCareer}

View File

@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from "react";
import { Sheet, SheetTrigger, SheetContent } from "./ui/sheet.js";
import { Card, CardContent } from "./ui/card.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
import { MessageCircle } from "lucide-react";
/**
* ChatDrawer Aptiva freetier assistant (pure **JavaScript** version)
*
* Floating FAB slideout Sheet.
* Streams SSE from `/api/chat/free`.
* JWT is read from `localStorage('token')` (same key CareerExplorer uses).
*/
export default function ChatDrawer({ pageContext = "explorer", snapshot = {} }) {
// ──────────────────────────────────────────────────────────────── state
const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [messages, setMessages] = useState([]); // { role, content }
const listRef = useRef(null);
// ─────────────────────────────────────────────────────────── autoscroll
useEffect(() => {
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 rerender 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 (
<Sheet open={open} onOpenChange={setOpen}>
{/* Floating action button */}
<SheetTrigger>
<button
aria-label="Open chat"
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700"
>
<MessageCircle size={24} />
</button>
</SheetTrigger>
{/* Drawer */}
<SheetContent
side="right"
className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]"
>
{/* header */}
<div className="sticky top-0 z-10 border-b bg-white px-4 py-3 font-semibold">
{pageContext === "explorer" ? "Career Explorer Guide" : "Programs Guide"}
</div>
{/* messages list */}
<div ref={listRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm">
{messages.map((m, i) => (
<div
key={i}
className={m.role === "user" ? "text-right" : "text-left text-gray-800"}
>
{m.content}
</div>
))}
</div>
{/* input */}
<div className="border-t p-4">
<form
onSubmit={(e) => {
e.preventDefault();
sendPrompt();
}}
className="flex gap-2"
>
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask me anything…"
className="flex-1"
/>
<Button type="submit" disabled={!prompt.trim()}>
Send
</Button>
</form>
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,92 @@
import { createContext, useContext, useState } from "react";
import { createPortal } from "react-dom";
/**
* Minimal <Sheet> implementation (slidein drawer) using plain React + Tailwind.
*
* Usage:
* <Sheet>
* <SheetTrigger></SheetTrigger>
* <SheetContent side="right"></SheetContent>
* </Sheet>
*/
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 (
<SheetContext.Provider value={{ open, setOpen }}>
{children}
</SheetContext.Provider>
);
}
export function SheetTrigger({ asChild, children, ...rest }) {
const { setOpen } = useContext(SheetContext);
const trigger = (
<button
{...rest}
onClick={(e) => {
rest.onClick?.(e);
setOpen(true);
}}
>
{children}
</button>
);
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(
<div className="fixed inset-0 z-50">
{/* backdrop */}
<div
className="absolute inset-0 bg-black/50" onClick={() => setOpen(false)}
/>
{/* panel */}
<div
className={`absolute bg-white shadow-xl transition-transform duration-300 ${positionMap[side]} ${
open ? "translate-x-0 translate-y-0" : translateMap[side]
} ${className}`}
>
{children}
</div>
</div>,
sheetRoot
);
}

View File

@ -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]);
}

View File

@ -1,6 +1,14 @@
// tailwind.config.js // tailwind.config.js
module.exports = { module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'], 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: { theme: {
extend: { extend: {
/* brand colours */ /* brand colours */

Binary file not shown.