dev1/backend/utils/chatFreeEndpoint.js

313 lines
12 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";
/* 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 (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*.
• 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 its 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