dev1/backend/utils/chatFreeEndpoint.js
2025-07-02 11:36:14 +00:00

172 lines
5.4 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.

// 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" });
}
}
);
}