/* ─── backend/utils/chatFreeEndpoint.js (TOP-OF-FILE REPLACEMENT) ───────── */ import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; import { vectorSearch } from "./vectorSearch.js"; /* Resolve current directory ─────────────────────────────────────────────── */ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /* Directories ───────────────────────────────────────────────────────────── */ /* repoRoot = “…/aptiva-dev1-app” (one level up from backend/) */ const repoRoot = path.resolve(__dirname, "..", ".."); /* assetsDir = “…/aptiva-dev1-app/src/assets” (where the JSONs live) */ const assetsDir = path.join(repoRoot, "src", "assets"); /* FAQ SQLite DB (unchanged) */ const FAQ_PATH = path.join(repoRoot, "backend", "user_profile.db"); /* Constants ─────────────────────────────────────────────────────────────── */ const FAQ_THRESHOLD = 0.80; const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"]; /* Load tool manifests just once at boot ─────────────────────────────────── */ const BOT_TOOLS = JSON.parse( await fs.readFile(path.join(assetsDir, "botTools.json"), "utf8") ); const PAGE_TOOLMAP = JSON.parse( await fs.readFile(path.join(assetsDir, "pageToolMap.json"), "utf8") ); /* ---------- 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.”" : "") }; }; const INTEREST_PLAYBOOK = ` ### When the user is on **CareerExplorer** and no career tiles are visible 1. Explain there are two ways to begin: • Interest Inventory (7-minute, 60-question survey) • Manual search (type a career in the “Search for Career” bar) 2. If the user chooses **Inventory** 1. Tell them to click the green **“Start Interest Inventory”** button at the top of the page. 2. Explain the answer keys (A = Agree, U = Unsure, D = Dislike) and that each click advances to the next question. 3. Wait while the UI runs the survey. **Do NOT collect answers inside chat.** 4. When career tiles appear, say: “Great! Your matches are listed below. Click any blue tile for details.” 3. If the user chooses **Manual search** 1. Tell them to click the **search bar** and type at least three letters. 2. After they pick a suggestion, remind them to click the blue tile to open its details. 4. **Never call \`getONetInterestQuestions\` or \`submitInterestInventory\` yourself.** 5. After tiles appear, you may call salary, projection, skills, or other data tools to answer follow-up questions. `; const CAREER_EXPLORER_FEATURES = ` ### Aptiva Career Explorer — features you can (and should) guide the user to • **Search bar** – type ≥3 letters, pick a suggestion, then click the blue tile. • **Interest Inventory** – green “Start Interest Inventory” button (60 Qs, 7 min). • **Blue career tile** – opens a modal with: • *Overview* tab (description & “Day-in-the-Life” tasks) • *Salary* tab (regional & national percentiles) • *Projections* tab (state + national growth) • *AI Risk* tab (Aptiva’s proprietary impact level) • **Add to Comparison** button – builds a side-by-side table above the tiles. • **Filters** dropdowns – “Preparation Level” (Job Zone 1-5) and “Fit Level” (Best / Great / Good). • **Reload Career Suggestions** – re-runs your interest-based match with updated filters. • **Select for Education** – jumps to Educational Programs with the career’s CIP codes. • When users ask: • “Which is better?” → tell them to add both careers and open the comparison table. • “What’s a day in the life?” → tell them to open the modal’s *Overview* tab. • “How do I plan education?” → tell them to click *Select for Education*. • Use tools when numeric data is needed: • \`getSalaryData\`, \`getEconomicProjections\`, \`getAiRisk\`, \`getCareerDetails\`. • You may call \`addCareerToComparison\` or \`openCareerModal\` **only after the user has clearly asked you to do so** (e.g. “Yes, add it for me”). Always confirm first. `; /* ---------------------------------------------------------------------------- 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" }; } }, /* NEW — forward any UI tool-call to the browser via SSE */ async __forwardUiTool(name, argsObj, res) { res.write(`__tool:${name}:${JSON.stringify(argsObj)}\n`); if (typeof res.flush === "function") res.flush(); return { forwarded: true }; }, }; /* -------------------- UI TOOLS (CareerExplorer only) -------------------- */ const UI_TOOLS = [ { type: "function", function: { name: "addCareerToComparison", description: "Add a career tile to the comparison table in Career Explorer", parameters: { type: "object", properties: { socCode: { type: "string" } }, required: ["socCode"] } } }, { type: "function", function: { name: "openCareerModal", description: "Open the Career-details modal for the given SOC code", parameters: { type: "object", properties: { socCode: { type: "string" } }, required: ["socCode"] } } } ]; 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); let { system } = buildContext(req.user || {}, pageContext, intent); if (pageContext === "CareerExplorer") { system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES; } /* ── Build tool list for this request ────────────────────── */ let tools = intent === "support" ? [...SUPPORT_TOOLS] : []; const uiNamesForPage = PAGE_TOOLMAP[pageContext] || []; for (const def of BOT_TOOLS) { if (uiNamesForPage.includes(def.name)) { tools.push({ type: "function", function: def }); } } 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 }); /* ── keep state while a tool call streams in ─────────────── */ let pendingName = null; // addCareerToComparison let pendingArgs = ""; // '{"socCode":"15-2051"}' for await (const part of chatStream) { const delta = part.choices?.[0]?.delta || {}; /* 1️⃣ handle function / tool calls immediately */ if (delta.tool_calls?.length) { const callObj = delta.tool_calls[0]; const fn = callObj.function || {}; if (fn.name) pendingName = fn.name; // keep first if (fn.arguments) pendingArgs += fn.arguments; // append // Try to parse the JSON only when it’s complete let args; try { args = JSON.parse(pendingArgs); } catch { continue; } /* run the resolver */ let result; if (toolResolvers[pendingName]) { result = await toolResolvers[pendingName]({ ...args, user: req.user }, res); } else { result = await toolResolvers.__forwardUiTool(pendingName, args, res); } /* feed the result back to the model so it can finish */ const followStream = await openai.chat.completions.create({ model: "gpt-4o-mini", stream: true, messages: [ ...messages, { role: "assistant", tool_call_id: callObj.id, content: null }, { role: "tool", tool_call_id: callObj.id, content: JSON.stringify(result) } ] }); /* stream the follow-up answer */ for await (const follow of followStream) { const txt = follow.choices?.[0]?.delta?.content; if (txt) res.write(txt); } res.end(); return; // ✔ done } /* 2️⃣ normal text tokens */ if (!delta.tool_calls && delta.content) { res.write(delta.content); // SSE-safe } } // ← closes the for-await loop res.end(); // finished without tools } catch (err) { // ← closes the try block above console.error("/api/chat/free error:", err); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); } } } // ← closes the async (req,res) => { … } ); // ← closes app.post(…) } // ← closes export default chatFreeEndpoint