/* ─── 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") ); const FREE_PROMPT_MAX_CHARS = Number(process.env.FREE_PROMPT_MAX_CHARS || 1500); const FREE_BURST_WINDOW_MS = Number(process.env.FREE_BURST_WINDOW_MS || 5 * 60 * 1000); // 5 min const FREE_BURST_LIMIT = Number(process.env.FREE_BURST_LIMIT || 6); // 6 chats / 5 min const FREE_DAILY_LIMIT = Number(process.env.FREE_DAILY_LIMIT || 60); // 60 chats / day const FREE_CONCURRENCY_PER_U = Number(process.env.FREE_CONCURRENCY_PER_U || 1); const FREE_ALLOWED_ORIGINS = new Set( (process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(s=>s.trim()).filter(Boolean) ); // in-mem counters (per user if authed, else per IP) const usage = new Map(); // key -> { win: number[], dayStart: number, dayCount: number, inflight: number } const DAY = 24*60*60*1000; function getU(key){ let u=usage.get(key); if(!u){u={win:[],dayStart:Date.now(),dayCount:0,inflight:0}; usage.set(key,u);} return u; } function clean(u){ if(Date.now()-u.dayStart>=DAY){u.dayStart=Date.now();u.dayCount=0;u.win.length=0;} const cut=Date.now()-FREE_BURST_WINDOW_MS; while(u.win.length&&u.win[0] 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 { // ---- Guards (before writing any headers) ---- const origin = req.headers.origin || ''; if (origin && !FREE_ALLOWED_ORIGINS.has(origin)) { return res.status(403).json({ error: 'origin_not_allowed' }); } const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {}; const p = String(prompt || '').trim(); if (!p) return res.status(400).json({ error: "Empty prompt" }); if (p.length > FREE_PROMPT_MAX_CHARS) { return res.status(413).json({ error: "prompt_too_long" }); } // per-user/IP light limits: 5-min burst, daily, 1 concurrent const key = String((req.user && (req.user.id || req.user.sub)) || req.ip); const u = getU(key); clean(u); if (u.inflight >= FREE_CONCURRENCY_PER_U) { res.set('Retry-After','3'); return res.status(429).json({ error:'chat_in_progress' }); } if (u.dayCount >= FREE_DAILY_LIMIT) return res.status(429).json({ error:'daily_limit_reached' }); if (u.win.length >= FREE_BURST_LIMIT) { const retryMs = Math.max(0, (u.win[0] + FREE_BURST_WINDOW_MS) - Date.now()); res.set('Retry-After', String(Math.ceil(retryMs/1000))); return res.status(429).json({ error:'slow_down' }); } u.inflight++; u.dayCount++; u.win.push(Date.now()); res.on('finish', ()=>{ u.inflight = Math.max(0, u.inflight-1); }); res.on('close', ()=>{ u.inflight = Math.max(0, u.inflight-1); }); /* ---------- 0️⃣ FAQ fast-path ---------- */ let faqHit = null; try { const { data } = await openai.embeddings.create({ model: "text-embedding-3-small", input: p }); 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: p } ]; const chatStream = await openai.chat.completions.create({ model : "gpt-4o-mini", stream : true, messages, }); const headers = { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" // disables Nginx/ALB buffering }; if (req.httpVersionMajor < 2) headers.Connection = "keep-alive"; res.writeHead(200, headers); res.flushHeaders?.(); const sendChunk = (txt="") => { res.write(txt); res.flush?.(); }; 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" }); } } } ); }