172 lines
5.4 KiB
JavaScript
172 lines
5.4 KiB
JavaScript
// utils/chatFreeEndpoint.js
|
||
import path from "path";
|
||
import { fileURLToPath } from "url";
|
||
import { vectorSearch } from "./vectorSearch.js";
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
const rootPath = path.resolve(__dirname, "..", ".."); // backend/
|
||
const FAQ_PATH = path.join(rootPath, "user_profile.db");
|
||
const FAQ_THRESHOLD = 0.80;
|
||
const HELP_TRIGGERS = ["help","error","bug","issue","login","password","support"];
|
||
|
||
/* ---------- 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.”"
|
||
: "")
|
||
};
|
||
};
|
||
|
||
/* ----------------------------------------------------------------------------
|
||
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 { 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);
|
||
const { system } = buildContext(req.user || {}, pageContext, intent);
|
||
const tools = intent === "support" ? SUPPORT_TOOLS : [];
|
||
|
||
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
|
||
});
|
||
|
||
/* ---------- SSE headers ---------- */
|
||
res.setHeader("Content-Type", "text/event-stream");
|
||
res.setHeader("Cache-Control", "no-cache");
|
||
res.setHeader("Connection", "keep-alive");
|
||
|
||
for await (const part of chatStream) {
|
||
const delta = part.choices?.[0]?.delta || {};
|
||
const chunk = delta.content || "";
|
||
if (chunk) res.write(chunk); // ← plain text, no “data:” prefix
|
||
}
|
||
res.end();
|
||
|
||
/* ---------- tool calls ---------- */
|
||
chatStream.on("tool", async call => {
|
||
const fn = toolResolvers[call.name];
|
||
if (!fn) return;
|
||
try {
|
||
const args = JSON.parse(call.arguments || "{}");
|
||
await fn({ ...args, user: req.user || {} });
|
||
} catch (err) {
|
||
console.error("[tool resolver]", err);
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error("/api/chat/free error:", err);
|
||
res.status(500).json({ error: "Internal server error" });
|
||
}
|
||
}
|
||
);
|
||
}
|