AI agent for CareerExplorer: AddtoComparison

This commit is contained in:
Josh 2025-07-03 11:32:31 +00:00
parent 2160f20f93
commit 58a8e15e09
12 changed files with 1030 additions and 181 deletions

View File

@ -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
**************************************************/

View File

@ -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 (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
----------------------------------------------------------------------------- */
@ -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 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
}
});
} 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
View File

@ -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",

View File

@ -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
View 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');

View File

@ -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
View 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"
]
}
}
]

View File

@ -0,0 +1,17 @@
{
"EducationalProgramsPage": [
"getDistanceInMiles",
"getSchoolsForCIPs",
"getTuitionForCIPs"
],
"LoanRepayment": [
"getDistanceInMiles",
"getTuitionForCIPs"
],
"CareerExplorer": [
"getEconomicProjections",
"getSalaryData",
"addCareerToComparison",
"openCareerModal"
]
}

View File

@ -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 youre 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&nbsp;
<a
href="https://www.onetcenter.org"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
O*NET OnLine
</a>
&nbsp;by the U.S. Department of Labor, Employment & Training Administration
(USDOL/ETA). Used under the&nbsp;
<a
href="https://creativecommons.org/licenses/by/4.0/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
CC&nbsp;BY&nbsp;4.0 license
</a>
. **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are
enriched with resources from the&nbsp;
<a
href="https://www.bls.gov"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Bureau of Labor Statistics
</a>
&nbsp;and program information from the&nbsp;
<a
href="https://nces.ed.gov"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
National Center for Education Statistics
</a>
.
</div>
</div>
);
}

View File

@ -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 freetier assistant (pure **JavaScript** version)
*
* Floating FAB slideout 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);
// ─────────────────────────────────────────────────────────── autoscroll
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 rerender 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 couldnt complete ${name}.\n`);
}
} else {
console.warn("No uiToolHandler for", name);
pushAssistant(`\n(UI handler “${name}” isnt 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 couldnt 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) => {

View File

@ -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
}

Binary file not shown.