313 lines
12 KiB
JavaScript
313 lines
12 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";
|
||
|
||
/* 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
|