287 lines
11 KiB
JavaScript
287 lines
11 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"];
|
||
|
||
/* 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 {
|
||
res.writeHead(200, {
|
||
"Content-Type": "text/plain; charset=utf-8",
|
||
"Cache-Control": "no-cache",
|
||
Connection : "keep-alive",
|
||
"X-Accel-Buffering": "no"
|
||
});
|
||
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;
|
||
}
|
||
|
||
/* 2) Append task catalogue so the bot can describe valid actions */
|
||
const isPremium = req.user?.plan_type === "premium";
|
||
system +=
|
||
"\n\n### 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`;
|
||
}
|
||
|
||
/* ── 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" });
|
||
}
|
||
}
|
||
}
|
||
);
|
||
}
|