dev1/backend/utils/chatFreeEndpoint.js
Josh 888bdd2939
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Runtime hardening, logs, rate limits
2025-08-28 18:03:45 +00:00

440 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ─── 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 = '<<APTIVA TASK CATALOGUE>>';
/* 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]<cut) u.win.shift(); }
/* ---------- 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 (Aptivas 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 careers CIP codes.
• When users ask:
• “Which is better?” → tell them to add both careers and open the comparison table.
• “Whats a day in the life?” → tell them to open the modals *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 cant 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 <RetirementPlanner> 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" });
}
}
}
);
}