/* ─── 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"; import { fuzzyCareerLookup } from "./fuzzyCareerLookup.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"]; const TASKS_SENTINEL = '<>'; /* 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.”" : "") }; }; /** Returns a \n-joined bullet list of banner + page tasks */ const taskListForPage = (page = "", isPremium = false) => { const banner = (SUPPORT_TASKS.global_banner || []) // hide the “Upgrade” action for premium users .filter(t => isPremium || t.id !== "GN-07"); const pageTasks = SUPPORT_TASKS[page] || []; return [...banner, ...pageTasks] .map(t => `• ${t.label}`) .join("\n"); }; 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 **Interest Inventory** 1. Tell them to open the Interest Inventory from the top by Clicking "Find Your Career" then "Interest Inventory". 2. Explain the answer keys (A = Agree, U = Unsure, D = Dislike); each click moves to the next question. 3. Wait while the UI runs the survey (do **not** collect answers in chat). 4. When the survey finishes, blue career tiles appear; tell them to click any tile to open its detail modal. 3. If the user chooses **Career Search** 3. If the user chooses **Manual search** 1. Tell them to click the **search bar** and type at least three letters. 2. When the suggestion list appears, they should select the desired career. 3. A detail modal opens automatically — no blue tile in this flow. 4. After a modal is open, you can guide them to salary, projections, AI-risk, etc. `; 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*. `; const SUPPORT_TASKS = JSON.parse( await fs.readFile( path.join(repoRoot, "src", "ai", "agent_support_reference.json"), "utf8" ) ); /* ---------------------------------------------------------------------------- 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 headers = { // streaming MIME type – browsers still treat it as text, but // it signals “keep pushing” semantics more clearly "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" // disables Nginx/ALB buffering }; // “Connection” is allowed **only** on HTTP/1.x if (req.httpVersionMajor < 2) { headers.Connection = "keep-alive"; } res.writeHead(200, headers); res.flushHeaders?.(); const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); }; const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = 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); /* ---------- system-prompt scaffold ---------- */ let { system } = buildContext(req.user || {}, pageContext, intent); /* 1) Add master playbooks per page (optional) */ if (pageContext === "CareerExplorer") { system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES; } /* ---- EducationalProgramsPage ------------------------------------ */ if (pageContext === "EducationalProgramsPage") { /* snapshot already comes over the wire from the front-end */ const { careerCtx = {}, // { socCode, careerTitle, cipCodes[] } ksaCtx = {}, // { total, topKnow[], topSkill[] } filterCtx = {}, // { sortBy, maxTuition, maxDistance, inStateOnly } schoolCtx = {} // { count } } = snapshot || {}; system += ` ### Current context: Educational Programs ${careerCtx.socCode ? `Career : ${careerCtx.careerTitle} (SOC ${careerCtx.socCode}) CIP codes : ${careerCtx.cipCodes?.join(", ") || "n/a"}` : "No career selected"} ${ksaCtx.total ? `KSA summary : ${ksaCtx.total} items • Top Knowledge : ${ksaCtx.topKnow?.join(", ") || "—"} • Top Skills : ${ksaCtx.topSkill?.join(", ") || "—"}` : ""} Filters in effect • Sort by : ${filterCtx.sortBy || "tuition"} • Max tuition : $${filterCtx.maxTuition ?? "—"} • Max distance : ${filterCtx.maxDistance ?? "—"} mi • In-state only : ${filterCtx.inStateOnly ? "yes" : "no"} Matching schools : ${schoolCtx.count ?? 0} ${Array.isArray(schoolCtx.sample) && schoolCtx.sample.length ? `Sample (top ${schoolCtx.sample.length}) ${schoolCtx.sample .map((s,i)=>`${i+1}. ${s.name} – $${s.inState||"?"} in-state, ${s.distance!==null? s.distance+" mi":"distance n/a"}`) .join("\n")}` : ""} (Remember: you can’t click—just explain the steps.)`; } /* 2) Append task catalogue once per conversation ─────────── */ const alreadySent = chatHistory.some( m => m.role === 'system' && m.content?.includes(TASKS_SENTINEL) ); if (!alreadySent) { const isPremium = req.user?.plan_type === 'premium'; system += `\n\n${TASKS_SENTINEL}\n` + // ← sentinel tag '### What the user can do on this screen\n' + taskListForPage(pageContext, isPremium) + '\n\n(Remember: do not click for the user; only explain the steps.)'; } const modalPayload = snapshot?.modalCtx; if (modalPayload) { const { socCode = "n/a", title = "n/a", aiRisk = "n/a", salary = {}, projections = {}, description = {}, tasks = [] } = modalPayload; system += `\n\n### Current career in focus\n` + `SOC: ${socCode} | Title: ${title}\n` + `AI-risk: ${aiRisk}\n` + `Median salary – regional: $${salary.regional ?? "n/a"}, ` + `national: $${salary.national ?? "n/a"}\n` + `Projections: ${JSON.stringify(projections)}` + `Description: ${description}\n` + `Key tasks: ${tasks.slice(0,5).join("; ")}\n`; } /* ---- CareerRoadmap extras ---- */ if (pageContext === "CareerRoadmap" && snapshot) { const { careerCtx={}, salaryCtx={}, econCtx={}, roadmapCtx={} } = snapshot; system += "\n\n### Current context: Career Road-map\n"; if (careerCtx.title) { system += `Career : ${careerCtx.title} (SOC ${careerCtx.socCode||'n/a'})\n`; } else { system += "No career selected yet\n"; } if (salaryCtx.userSalary) { system += `Salary : $${salaryCtx.userSalary.toLocaleString()} (you)\n`; if (salaryCtx.regionalMedian) system += ` • Regional median : $${salaryCtx.regionalMedian.toLocaleString()}\n`; if (salaryCtx.nationalMedian) system += ` • National median : $${salaryCtx.nationalMedian.toLocaleString()}\n`; } if (econCtx.stateGrowth || econCtx.nationalGrowth) { system += "Growth outlook\n"; if (econCtx.stateGrowth !== null) system += ` • State : ${econCtx.stateGrowth}%\n`; if (econCtx.nationalGrowth !== null) system += ` • Nation : ${econCtx.nationalGrowth}%\n`; } system += `Road-map : ${roadmapCtx.done}/${roadmapCtx.milestones} milestones complete, ` + `${roadmapCtx.yearsAhead} y horizon\n`; system += "(Explain steps only; never click for the user.)"; } /* ────────────────────────────────────────────────────────────── RetirementPlanner extras (NEW) - front-end already sends `snapshot` shaped like: { scenarioCtx : { count, selectedId, selectedTitle }, simCtx : { years, interest }, // eg 50 | "No Interest" chartCtx : { nestEgg } // projected value, number } ---------------------------------------------------------------- */ if (pageContext === "RetirementPlanner" && snapshot) { const { scenarioCtx = {}, simCtx = {}, chartCtx = {} } = snapshot; system += "\n\n### Current context: Retirement Planner\n"; if (scenarioCtx.count != null) { system += `Scenarios loaded : ${scenarioCtx.count}\n`; if (scenarioCtx.selectedTitle) system += `Active scenario : “${scenarioCtx.selectedTitle}”\n`; } if (simCtx.years != null) system += `Simulation horizon : ${simCtx.years} years\n`; if (simCtx.interest) system += `Interest model : ${simCtx.interest}\n`; if (chartCtx.nestEgg != null) system += `Projected nest-egg : $${chartCtx.nestEgg.toLocaleString()}\n`; system += "(Explain steps only; never click for the user. Refer to buttons/inputs by their labels from the task catalogue.)"; } /* ── Build tool list for this request ────────────────────── */ const tools = [...SUPPORT_TOOLS]; // guidance mode → support-only; let 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, }); for await (const part of chatStream) { const txt = part.choices?.[0]?.delta?.content; if (txt) sendChunk(txt); } // tell the front-end we are done and close the stream res.write("\n"); res.end(); } catch (err) { console.error("/api/chat/free error:", err); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); } } } ); }