410 lines
16 KiB
JavaScript
410 lines
16 KiB
JavaScript
/* ─── 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")
|
||
);
|
||
|
||
/* ---------- 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 <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: 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" });
|
||
}
|
||
}
|
||
}
|
||
);
|
||
}
|