AI agent for CareerExplorer: AddtoComparison
This commit is contained in:
parent
2160f20f93
commit
58a8e15e09
@ -299,6 +299,21 @@ async function geocodeZipCode(zipCode) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @aiTool {
|
||||
"name": "getDistanceInMiles",
|
||||
"description": "Return driving distance and duration between the user ZIP and destination(s)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userZipcode": { "type": "string" },
|
||||
"destinations": { "type": "string", "description": "Pipe-separated lat,lng pairs or addresses" }
|
||||
},
|
||||
"required": ["userZipcode", "destinations"]
|
||||
},
|
||||
"pages": ["EducationalProgramsPage", "LoanRepayment"],
|
||||
"write": false
|
||||
} */
|
||||
|
||||
// Distance
|
||||
app.post('/api/maps/distance', async (req, res) => {
|
||||
const { userZipcode, destinations } = req.body;
|
||||
@ -496,6 +511,27 @@ app.get('/api/cip/:socCode', (req, res) => {
|
||||
res.status(404).json({ error: 'CIP code not found' });
|
||||
});
|
||||
|
||||
/** @aiTool {
|
||||
"name": "getSchoolsForCIPs",
|
||||
"description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cipCodes": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated CIP prefixes, e.g. \"1101,1103\""
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Two-letter state abbreviation, e.g. \"GA\""
|
||||
}
|
||||
},
|
||||
"required": ["cipCodes", "state"]
|
||||
},
|
||||
"pages": ["EducationalProgramsPage"],
|
||||
"write": false
|
||||
} */
|
||||
|
||||
/**************************************************
|
||||
* Single schools / tuition / etc. routes
|
||||
**************************************************/
|
||||
@ -553,6 +589,21 @@ app.get('/api/schools', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** @aiTool {
|
||||
"name": "getTuitionForCIPs",
|
||||
"description": "Return in-state / out-state tuition rows for schools matching CIP prefixes in a given state",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cipCodes": { "type": "string", "description": "Comma-separated prefixes, e.g. \"1101,1103\"" },
|
||||
"state": { "type": "string", "description": "Two-letter state code, e.g. \"GA\"" }
|
||||
},
|
||||
"required": ["cipCodes", "state"]
|
||||
},
|
||||
"pages": ["EducationalProgramsPage", "LoanRepayment"],
|
||||
"write": false
|
||||
} */
|
||||
|
||||
// tuition
|
||||
app.get('/api/tuition', (req, res) => {
|
||||
const { cipCodes, state } = req.query;
|
||||
@ -603,6 +654,21 @@ app.get('/api/tuition', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** @aiTool {
|
||||
"name": "getEconomicProjections",
|
||||
"description": "Return state and national employment projections for a SOC code",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"socCode": { "type": "string" },
|
||||
"state": { "type": "string", "description": "Optional state abbreviation" }
|
||||
},
|
||||
"required": ["socCode"]
|
||||
},
|
||||
"pages": ["CareerExplorer"],
|
||||
"write": false
|
||||
} */
|
||||
|
||||
/**************************************************
|
||||
* SINGLE route for projections from economicproj.json
|
||||
**************************************************/
|
||||
@ -661,6 +727,21 @@ app.get('/api/projections/:socCode', (req, res) => {
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
/** @aiTool {
|
||||
"name": "getSalaryData",
|
||||
"description": "Return residential area and national salary percentiles for a SOC code",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"socCode": { "type": "string" },
|
||||
"area": { "type": "string", "description": "User's residential area" }
|
||||
},
|
||||
"required": ["socCode"]
|
||||
},
|
||||
"pages": ["CareerExplorer"],
|
||||
"write": false
|
||||
} */
|
||||
|
||||
/**************************************************
|
||||
* Salary route
|
||||
**************************************************/
|
||||
|
@ -1,15 +1,36 @@
|
||||
// utils/chatFreeEndpoint.js
|
||||
import path from "path";
|
||||
/* ─── 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";
|
||||
|
||||
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");
|
||||
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";
|
||||
@ -26,6 +47,47 @@ const buildContext = (user = {}, page = "", intent = "guide") => {
|
||||
};
|
||||
};
|
||||
|
||||
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
|
||||
----------------------------------------------------------------------------- */
|
||||
@ -63,9 +125,45 @@ export default function chatFreeEndpoint(
|
||||
} 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",
|
||||
@ -122,14 +220,24 @@ export default function chatFreeEndpoint(
|
||||
/* --------------------------------------- */
|
||||
|
||||
const intent = classifyIntent(prompt);
|
||||
const { system } = buildContext(req.user || {}, pageContext, intent);
|
||||
const tools = intent === "support" ? SUPPORT_TOOLS : [];
|
||||
let { system } = buildContext(req.user || {}, pageContext, intent);
|
||||
if (pageContext === "CareerExplorer") {
|
||||
system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES;
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role:"system", content: system },
|
||||
...chatHistory,
|
||||
{ role:"user", content: prompt }
|
||||
];
|
||||
/* ── 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",
|
||||
@ -139,33 +247,67 @@ export default function chatFreeEndpoint(
|
||||
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");
|
||||
/* ── 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 || {};
|
||||
const chunk = delta.content || "";
|
||||
if (chunk) res.write(chunk); // ← plain text, no “data:” prefix
|
||||
}
|
||||
res.end();
|
||||
const delta = part.choices?.[0]?.delta || {};
|
||||
|
||||
/* ---------- 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);
|
||||
/* 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
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("/api/chat/free error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
||||
/* 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
|
327
package-lock.json
generated
327
package-lock.json
generated
@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "aptiva-dev1-app",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
@ -38,7 +39,7 @@
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cron": "^4.1.0",
|
||||
"openai": "^4.97.0",
|
||||
"openai": "^4.104.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"pluralize": "^8.0.0",
|
||||
@ -60,10 +61,12 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"glob": "^11.0.3",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
@ -437,12 +440,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz",
|
||||
"integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.7"
|
||||
"@babel/types": "^7.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@ -2049,9 +2052,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz",
|
||||
"integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
|
||||
"integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
@ -2499,6 +2502,29 @@
|
||||
"deprecated": "Use @eslint/object-schema instead",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@ -2777,6 +2803,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/reporters/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/reporters/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@ -6196,6 +6243,28 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/cacache/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/cacache/node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
@ -9588,6 +9657,27 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
|
||||
@ -9952,21 +10042,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
|
||||
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.0.3",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@ -9990,6 +10083,75 @@
|
||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/glob/node_modules/jackspeak": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/lru-cache": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
|
||||
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/path-scurry": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/global-modules": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
|
||||
@ -11553,6 +11715,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jest-config/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-diff": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
|
||||
@ -11881,6 +12064,27 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-runtime/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-serializer": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz",
|
||||
@ -13713,6 +13917,28 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/npmlog": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
|
||||
@ -17084,6 +17310,27 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "2.79.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
||||
@ -19087,6 +19334,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
@ -20353,6 +20621,27 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/workbox-build/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
|
@ -33,7 +33,7 @@
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"mysql2": "^3.14.1",
|
||||
"node-cron": "^4.1.0",
|
||||
"openai": "^4.97.0",
|
||||
"openai": "^4.104.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"pluralize": "^8.0.0",
|
||||
@ -58,7 +58,9 @@
|
||||
"start": "react-scripts start --host 0.0.0.0",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"eject": "react-scripts eject",
|
||||
"gen:tools": "node scripts/genTools.cjs",
|
||||
"postinstall": "npm run gen:tools"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@ -84,10 +86,12 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"glob": "^11.0.3",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
|
40
scripts/genTools.cjs
Normal file
40
scripts/genTools.cjs
Normal file
@ -0,0 +1,40 @@
|
||||
/* Generates:
|
||||
* – src/assets/botTools.json
|
||||
* – src/assets/pageToolMap.json
|
||||
*
|
||||
* It scans backend/server*.js for comment blocks like:
|
||||
* /** @aiTool { "name": "...", "pages": ["CareerExplorer"], ... } *\/
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const { parse } = require('@babel/parser');
|
||||
|
||||
const files = glob.sync('backend/server*.js'); // adjust if server paths differ
|
||||
|
||||
const tools = [];
|
||||
const pageToolMap = {};
|
||||
|
||||
for (const file of files) {
|
||||
const code = fs.readFileSync(file, 'utf8');
|
||||
const ast = parse(code, { sourceType: 'module', plugins: ['jsx'] });
|
||||
|
||||
(ast.comments || []).forEach(c => {
|
||||
if (!c.value.includes('@aiTool')) return;
|
||||
const meta = JSON.parse(c.value.replace(/^[\s\S]*@aiTool\s*/, '').trim());
|
||||
|
||||
tools.push({
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
parameters: meta.parameters,
|
||||
});
|
||||
|
||||
(meta.pages || ['Global']).forEach(p =>
|
||||
(pageToolMap[p] ||= []).push(meta.name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync('src/assets', { recursive: true });
|
||||
fs.writeFileSync('src/assets/botTools.json', JSON.stringify(tools, null, 2));
|
||||
fs.writeFileSync('src/assets/pageToolMap.json', JSON.stringify(pageToolMap, null, 2));
|
||||
console.log('✅ botTools.json & pageToolMap.json generated');
|
37
src/App.js
37
src/App.js
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
@ -32,15 +32,43 @@ import RetirementPlanner from './components/RetirementPlanner.js';
|
||||
import ResumeRewrite from './components/ResumeRewrite.js';
|
||||
import LoanRepaymentPage from './components/LoanRepaymentPage.js';
|
||||
import usePageContext from './utils/usePageContext.js';
|
||||
import ChatDrawer from './components/ChatDrawer.js';
|
||||
|
||||
|
||||
|
||||
export const ProfileCtx = React.createContext();
|
||||
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
usePageContext();
|
||||
const pageContext = usePageContext();
|
||||
|
||||
/* ------------------------------------------
|
||||
ChatDrawer – route-aware tool handlers
|
||||
------------------------------------------ */
|
||||
const uiToolHandlers = useMemo(() => {
|
||||
if (pageContext === "CareerExplorer") {
|
||||
return {
|
||||
// __tool:addCareerToComparison:{"socCode":"15-2051","careerName":"Data Scientist"}
|
||||
addCareerToComparison: ({ socCode, careerName }) => {
|
||||
console.log('[dispatch]', socCode, careerName);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("add-career", { detail: { socCode, careerName } })
|
||||
);
|
||||
},
|
||||
|
||||
// __tool:openCareerModal:{"socCode":"15-2051"}
|
||||
openCareerModal: ({ socCode }) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-career", { detail: { socCode } })
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
return {}; // every other page exposes no UI tools
|
||||
}, [pageContext]);
|
||||
|
||||
|
||||
// Auth states
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
@ -523,6 +551,11 @@ function App() {
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<ChatDrawer
|
||||
pageContext={pageContext}
|
||||
uiToolHandlers={uiToolHandlers}
|
||||
/>
|
||||
|
||||
{/* Session Handler (Optional) */}
|
||||
<SessionExpiredHandler />
|
||||
</div>
|
||||
|
130
src/assets/botTools.json
Normal file
130
src/assets/botTools.json
Normal file
@ -0,0 +1,130 @@
|
||||
[
|
||||
{
|
||||
"name": "getDistanceInMiles",
|
||||
"description": "Return driving distance and duration between the user ZIP and destination(s)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userZipcode": {
|
||||
"type": "string"
|
||||
},
|
||||
"destinations": {
|
||||
"type": "string",
|
||||
"description": "Pipe-separated lat,lng pairs or addresses"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"userZipcode",
|
||||
"destinations"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getSchoolsForCIPs",
|
||||
"description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cipCodes": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated CIP prefixes, e.g. \"1101,1103\""
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Two-letter state abbreviation, e.g. \"GA\""
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cipCodes",
|
||||
"state"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "addCareerToComparison",
|
||||
"description": "Add the career with the given SOC code to the comparison table in Career Explorer",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"socCode": {
|
||||
"type": "string",
|
||||
"description": "Full O*NET SOC code, e.g. \"15-2051\""
|
||||
}
|
||||
},
|
||||
"required": ["socCode"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "openCareerModal",
|
||||
"description": "Open the career-details modal for the given SOC code in Career Explorer",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"socCode": {
|
||||
"type": "string",
|
||||
"description": "Full O*NET SOC code, e.g. \"15-2051\""
|
||||
}
|
||||
},
|
||||
"required": ["socCode"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getTuitionForCIPs",
|
||||
"description": "Return in-state / out-state tuition rows for schools matching CIP prefixes in a given state",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cipCodes": {
|
||||
"type": "string",
|
||||
"description": "Comma-separated prefixes, e.g. \"1101,1103\""
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Two-letter state code, e.g. \"GA\""
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cipCodes",
|
||||
"state"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getEconomicProjections",
|
||||
"description": "Return state and national employment projections for a SOC code",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"socCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Optional state abbreviation"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"socCode"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "getSalaryData",
|
||||
"description": "Return residential area and national salary percentiles for a SOC code",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"socCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"area": {
|
||||
"type": "string",
|
||||
"description": "User's residential area"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"socCode"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
17
src/assets/pageToolMap.json
Normal file
17
src/assets/pageToolMap.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"EducationalProgramsPage": [
|
||||
"getDistanceInMiles",
|
||||
"getSchoolsForCIPs",
|
||||
"getTuitionForCIPs"
|
||||
],
|
||||
"LoanRepayment": [
|
||||
"getDistanceInMiles",
|
||||
"getTuitionForCIPs"
|
||||
],
|
||||
"CareerExplorer": [
|
||||
"getEconomicProjections",
|
||||
"getSalaryData",
|
||||
"addCareerToComparison",
|
||||
"openCareerModal"
|
||||
]
|
||||
}
|
@ -231,7 +231,6 @@ function CareerExplorer() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --------------------------------------
|
||||
// On mount, load suggestions from cache
|
||||
// --------------------------------------
|
||||
@ -763,6 +762,49 @@ const handleSelectForEducation = (career) => {
|
||||
return weightMap[priority][response] || 1;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/* ---------- add-to-comparison ---------- */
|
||||
const onAdd = (e) => {
|
||||
console.log('[onAdd] detail →', e.detail);
|
||||
const { socCode, careerName } = e.detail || {};
|
||||
if (!socCode) {
|
||||
console.warn('[add-career] missing socCode – aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. see if the career is already in the filtered list
|
||||
let career = filteredCareers.find((c) => c.code === socCode);
|
||||
|
||||
// 2. if not, make a stub so the list can still save
|
||||
if (!career) {
|
||||
career = {
|
||||
code : socCode,
|
||||
title: careerName || '(name unavailable)',
|
||||
fit : 'Good',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. push it into the comparison table
|
||||
addCareerToList(career);
|
||||
};
|
||||
|
||||
/* ---------- open-modal ---------- */
|
||||
const onOpen = (e) => {
|
||||
const { socCode } = e.detail || {};
|
||||
if (!socCode) return;
|
||||
const career = filteredCareers.find((c) => c.code === socCode);
|
||||
if (career) handleCareerClick(career);
|
||||
};
|
||||
|
||||
window.addEventListener('add-career', onAdd);
|
||||
window.addEventListener('open-career', onOpen);
|
||||
return () => {
|
||||
window.removeEventListener('add-career', onAdd);
|
||||
window.removeEventListener('open-career', onOpen);
|
||||
};
|
||||
}, [filteredCareers, addCareerToList, handleCareerClick]);
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Loading Overlay
|
||||
// ------------------------------------------------------
|
||||
@ -1017,11 +1059,6 @@ const explorerSnapshot = useMemo(() => {
|
||||
defaultMeaning={modalData.defaultMeaning}
|
||||
/>
|
||||
|
||||
<ChatDrawer
|
||||
pageContext="explorer"
|
||||
snapshot={explorerSnapshot} // the JSON you’re already building
|
||||
/>
|
||||
|
||||
{selectedCareer && (
|
||||
<CareerModal
|
||||
career={selectedCareer}
|
||||
@ -1035,35 +1072,47 @@ const explorerSnapshot = useMemo(() => {
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-xs text-gray-500 border-t pt-2">
|
||||
Career results and details provided by
|
||||
<a
|
||||
href="https://www.onetcenter.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
O*Net
|
||||
</a>
|
||||
, in partnership with
|
||||
<a
|
||||
href="https://www.bls.gov"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
Bureau of Labor Statistics
|
||||
</a>
|
||||
and
|
||||
<a
|
||||
href="https://nces.ed.gov"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
NCES
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
This page includes information from
|
||||
<a
|
||||
href="https://www.onetcenter.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
O*NET OnLine
|
||||
</a>
|
||||
by the U.S. Department of Labor, Employment & Training Administration
|
||||
(USDOL/ETA). Used under the
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
CC BY 4.0 license
|
||||
</a>
|
||||
. **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are
|
||||
enriched with resources from the
|
||||
<a
|
||||
href="https://www.bls.gov"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Bureau of Labor Statistics
|
||||
</a>
|
||||
and program information from the
|
||||
<a
|
||||
href="https://nces.ed.gov"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
National Center for Education Statistics
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,141 +1,196 @@
|
||||
// ────────────────────────────────── ChatDrawer.jsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Sheet, SheetTrigger, SheetContent } from "./ui/sheet.js";
|
||||
import { Card, CardContent } from "./ui/card.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* ChatDrawer – Aptiva free‑tier assistant (pure **JavaScript** version)
|
||||
*
|
||||
* ▸ Floating FAB → slide‑out Sheet.
|
||||
* ▸ Streams SSE from `/api/chat/free`.
|
||||
* ▸ JWT is read from `localStorage('token')` (same key CareerExplorer uses).
|
||||
*/
|
||||
export default function ChatDrawer({ pageContext = "explorer", snapshot = {} }) {
|
||||
// ──────────────────────────────────────────────────────────────── state
|
||||
const [open, setOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [messages, setMessages] = useState([]); // { role, content }
|
||||
/* ---------------------------------------------------------------
|
||||
Streams from /api/chat/free and executes UI-tool callbacks
|
||||
----------------------------------------------------------------*/
|
||||
export default function ChatDrawer({
|
||||
pageContext = "Home",
|
||||
snapshot = {},
|
||||
uiToolHandlers = {} // e.g. { addCareerToComparison, openCareerModal }
|
||||
}) {
|
||||
/* state */
|
||||
const [open, setOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [messages, setMessages] = useState([]); // { role, content }
|
||||
const listRef = useRef(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────── auto‑scroll
|
||||
console.log("CHATDRAWER-BUILD-TAG-2025-07-02");
|
||||
|
||||
/* auto-scroll */
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
listRef.current &&
|
||||
(listRef.current.scrollTop = listRef.current.scrollHeight);
|
||||
}, [messages]);
|
||||
|
||||
// ─────────────────────────────────────────────── helper: append chunk
|
||||
function appendAssistantChunk(chunk) {
|
||||
/* helper: stream-friendly append */
|
||||
const pushAssistant = (chunk) =>
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === "assistant") {
|
||||
// mutate copy – React will re‑render because we return new array
|
||||
const last = prev.at(-1);
|
||||
if (last?.role === "assistant") {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
content: last.content + chunk,
|
||||
content: last.content + chunk
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
// first assistant chunk → push new msg
|
||||
return [...prev, { role: "assistant", content: chunk }];
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────── send prompt
|
||||
/* send prompt */
|
||||
async function sendPrompt() {
|
||||
const text = prompt.trim();
|
||||
if (!text) return;
|
||||
|
||||
// optimistic user message
|
||||
setMessages((m) => [...m, { role: "user", content: text }]);
|
||||
setPrompt("");
|
||||
|
||||
const token = localStorage.getItem("token") || "";
|
||||
const body = JSON.stringify({
|
||||
prompt: text,
|
||||
pageContext,
|
||||
chatHistory: messages,
|
||||
snapshot
|
||||
});
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token") || "";
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Accept : "text/event-stream",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
};
|
||||
|
||||
const resp = await fetch("/api/chat/free", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: text,
|
||||
pageContext,
|
||||
chatHistory: messages,
|
||||
snapshot,
|
||||
}),
|
||||
headers,
|
||||
body
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(`Request failed ${resp.status}`);
|
||||
}
|
||||
|
||||
// stream the response
|
||||
const reader = resp.body.getReader();
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const { value, done: doneReading } = await reader.read();
|
||||
done = doneReading;
|
||||
if (value) {
|
||||
const chunk = decoder.decode(value);
|
||||
appendAssistantChunk(chunk);
|
||||
let buf = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
buf += decoder.decode(value);
|
||||
|
||||
/* 1️⃣ process every complete “__tool:” line in the buffer */
|
||||
|
||||
for (const lineRaw of chunk.split(/\n/)) { // ← NEW
|
||||
const line = lineRaw.trim();
|
||||
if (!line.startsWith("__tool:")) continue;
|
||||
|
||||
const firstColon = line.indexOf(":", 7);
|
||||
const name = line.slice(7, firstColon).trim();
|
||||
const argsJson = line.slice(firstColon + 1).trim();
|
||||
let args = {};
|
||||
try { args = JSON.parse(argsJson); } catch {/* keep {} */}
|
||||
|
||||
const fn = uiToolHandlers[name];
|
||||
if (typeof fn === "function") {
|
||||
try {
|
||||
await fn(args);
|
||||
pushAssistant(`\n✓ ${name} completed.\n`);
|
||||
} catch (err) {
|
||||
console.error("[uiTool handler]", err);
|
||||
pushAssistant(`\nSorry – couldn’t complete ${name}.\n`);
|
||||
}
|
||||
} else {
|
||||
console.warn("No uiToolHandler for", name);
|
||||
pushAssistant(`\n(UI handler “${name}” isn’t wired on this page.)\n`);
|
||||
}
|
||||
return; // finished processing this chunk
|
||||
}
|
||||
|
||||
|
||||
/* 2️⃣ legacy JSON tool payload _________________________ */
|
||||
let json;
|
||||
try { json = JSON.parse(chunk); } catch {/* not JSON */ }
|
||||
if (json && json.uiTool) {
|
||||
const fn = uiToolHandlers[json.uiTool];
|
||||
if (typeof fn === "function") {
|
||||
try {
|
||||
await fn(JSON.parse(json.args || "{}"));
|
||||
pushAssistant(`\n✓ ${json.uiTool} completed.\n`);
|
||||
} catch (err) {
|
||||
console.error("[uiTool handler]", err);
|
||||
pushAssistant(`\nSorry – couldn’t complete ${json.uiTool}.\n`);
|
||||
}
|
||||
} else {
|
||||
console.warn("No uiToolHandler for", json.uiTool);
|
||||
pushAssistant(
|
||||
`\n(UI handler “${json.uiTool}” is not wired in the page.)\n`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
/* plain assistant text */
|
||||
pushAssistant(chunk);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ChatDrawer] error", err);
|
||||
appendAssistantChunk("Sorry – something went wrong. Try again later.");
|
||||
console.error("[ChatDrawer] stream error", err);
|
||||
pushAssistant("Sorry — something went wrong. Please try again later.");
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────── key handler
|
||||
function handleKeyDown(e) {
|
||||
/* Enter submits */
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendPrompt();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── render
|
||||
/* UI */
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
{/* Floating action button */}
|
||||
{/* floating button */}
|
||||
<SheetTrigger>
|
||||
<button
|
||||
aria-label="Open chat"
|
||||
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700"
|
||||
>
|
||||
<MessageCircle size={24} />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<button
|
||||
aria-label="Open chat"
|
||||
className="fixed bottom-6 right-6 z-50 rounded-full bg-blue-600 p-3 text-white shadow-lg hover:bg-blue-700"
|
||||
>
|
||||
<MessageCircle size={24} />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
|
||||
{/* Drawer */}
|
||||
{/* drawer */}
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex max-h-screen w-[380px] flex-col px-0 md:w-[420px]"
|
||||
>
|
||||
{/* header */}
|
||||
<div className="sticky top-0 z-10 border-b bg-white px-4 py-3 font-semibold">
|
||||
{pageContext === "explorer" ? "Career Explorer Guide" : "Programs Guide"}
|
||||
{pageContext}
|
||||
</div>
|
||||
|
||||
{/* messages list */}
|
||||
<div ref={listRef} className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm">
|
||||
{/* transcript */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-4 overflow-y-auto px-4 py-2 text-sm"
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={m.role === "user" ? "text-right" : "text-left text-gray-800"}
|
||||
className={
|
||||
m.role === "user" ? "text-right" : "text-left text-gray-800"
|
||||
}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* input */}
|
||||
{/* prompt box */}
|
||||
<div className="border-t p-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
|
@ -1,15 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { sendSystemMessage } from '../utils/chatApi.js'; // you already call this for Jess
|
||||
// src/utils/usePageContext.js
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export default function usePageContext () {
|
||||
/* route → page-key map */
|
||||
const routeMap = [
|
||||
{ test: p => p.startsWith("/career-explorer"), page: "CareerExplorer" },
|
||||
{ test: p => p.startsWith("/educational-programs"), page: "EducationalProgramsPage" },
|
||||
{ test: p => p.startsWith("/career-roadmap"), page: "CareerRoadmap" },
|
||||
{ test: p => p.startsWith("/retirement"), page: "RetirementPlanner" },
|
||||
{ test: p => p.startsWith("/resume-rewrite"), page: "ResumeRewrite" },
|
||||
];
|
||||
|
||||
export default function usePageContext() {
|
||||
const { pathname } = useLocation();
|
||||
const [page, setPage] = useState("Home");
|
||||
|
||||
useEffect(() => {
|
||||
const page = pathname.split('/')[1] || 'Home';
|
||||
const featureFlags = window.__APTIVA_FLAGS__ || [];
|
||||
|
||||
// invisible system message for the adaptive assistant
|
||||
sendSystemMessage('page_context', { page, featureFlags });
|
||||
const found = routeMap.find(r => r.test(pathname));
|
||||
setPage(found ? found.page : "Home");
|
||||
}, [pathname]);
|
||||
|
||||
return page; // ← hook now RETURNS the page string
|
||||
}
|
||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user