diff --git a/backend/server2.js b/backend/server2.js index 0e85e34..314e21f 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -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 **************************************************/ diff --git a/backend/utils/chatFreeEndpoint.js b/backend/utils/chatFreeEndpoint.js index 30205fe..d4be88a 100644 --- a/backend/utils/chatFreeEndpoint.js +++ b/backend/utils/chatFreeEndpoint.js @@ -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 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 65a977b..543d6fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a85fa65..23101a5 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/scripts/genTools.cjs b/scripts/genTools.cjs new file mode 100644 index 0000000..9cbc675 --- /dev/null +++ b/scripts/genTools.cjs @@ -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'); diff --git a/src/App.js b/src/App.js index 3e4f9ce..1d439e8 100644 --- a/src/App.js +++ b/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() { + + {/* Session Handler (Optional) */} diff --git a/src/assets/botTools.json b/src/assets/botTools.json new file mode 100644 index 0000000..54bab63 --- /dev/null +++ b/src/assets/botTools.json @@ -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" + ] + } + } +] \ No newline at end of file diff --git a/src/assets/pageToolMap.json b/src/assets/pageToolMap.json new file mode 100644 index 0000000..777e57f --- /dev/null +++ b/src/assets/pageToolMap.json @@ -0,0 +1,17 @@ +{ + "EducationalProgramsPage": [ + "getDistanceInMiles", + "getSchoolsForCIPs", + "getTuitionForCIPs" + ], + "LoanRepayment": [ + "getDistanceInMiles", + "getTuitionForCIPs" + ], + "CareerExplorer": [ + "getEconomicProjections", + "getSalaryData", + "addCareerToComparison", + "openCareerModal" + ] +} \ No newline at end of file diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index cd9349c..4fa01a4 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -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} /> - - {selectedCareer && ( { )}
- Career results and details provided by - - {' '} - O*Net - - , in partnership with - - {' '} - Bureau of Labor Statistics - - and - - {' '} - NCES - - . -
+ This page includes information from  + + O*NET OnLine + +  by the U.S. Department of Labor, Employment & Training Administration + (USDOL/ETA). Used under the  + + CC BY 4.0 license + + . **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are + enriched with resources from the  + + Bureau of Labor Statistics + +  and program information from the  + + National Center for Education Statistics + + . + + ); } diff --git a/src/components/ChatDrawer.js b/src/components/ChatDrawer.js index e3d630f..ccca719 100644 --- a/src/components/ChatDrawer.js +++ b/src/components/ChatDrawer.js @@ -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 ( - {/* Floating action button */} + {/* floating button */} - - + + - {/* Drawer */} + {/* drawer */} - {/* header */}
- {pageContext === "explorer" ? "Career Explorer Guide" : "Programs Guide"} + {pageContext}
- {/* messages list */} -
+ {/* transcript */} +
{messages.map((m, i) => (
{m.content}
))}
- {/* input */} + {/* prompt box */}
{ diff --git a/src/utils/usePageContext.js b/src/utils/usePageContext.js index b4a123b..07609ef 100644 --- a/src/utils/usePageContext.js +++ b/src/utils/usePageContext.js @@ -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 } diff --git a/user_profile.db b/user_profile.db index 2fa80e7..57ef844 100644 Binary files a/user_profile.db and b/user_profile.db differ