From ec0ce1fce82f18f34d17db3a270388a00051e311 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 7 Jul 2025 14:51:55 +0000 Subject: [PATCH] AI agent context for CareerExplorer --- backend/utils/chatFreeEndpoint.js | 216 ++++++++++++---------------- backend/utils/fuzzyCareerLookup.js | 23 +++ src/App.js | 7 +- src/Root.js | 12 ++ src/ai/agent_support_reference.json | 46 ++++++ src/assets/botTools.json | 9 ++ src/assets/pageToolMap.json | 1 + src/components/CareerExplorer.js | 178 ++++++++++++++--------- src/components/ChatDrawer.js | 68 ++------- src/contexts/ChatCtx.js | 5 + src/index.js | 9 +- src/utils/PageFlagsContext.js | 13 ++ src/utils/usePageContext.js | 17 ++- user_profile.db | Bin 192512 -> 200704 bytes 14 files changed, 348 insertions(+), 256 deletions(-) create mode 100644 backend/utils/fuzzyCareerLookup.js create mode 100644 src/Root.js create mode 100644 src/ai/agent_support_reference.json create mode 100644 src/contexts/ChatCtx.js create mode 100644 src/utils/PageFlagsContext.js diff --git a/backend/utils/chatFreeEndpoint.js b/backend/utils/chatFreeEndpoint.js index d4be88a..bd2dc9c 100644 --- a/backend/utils/chatFreeEndpoint.js +++ b/backend/utils/chatFreeEndpoint.js @@ -4,6 +4,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { vectorSearch } from "./vectorSearch.js"; +import { fuzzyCareerLookup } from "./fuzzyCareerLookup.js"; /* Resolve current directory ─────────────────────────────────────────────── */ const __filename = fileURLToPath(import.meta.url); @@ -47,21 +48,34 @@ const buildContext = (user = {}, page = "", intent = "guide") => { }; }; +/** Returns a \n-joined bullet list of banner + page tasks */ +const taskListForPage = (page = "", isPremium = false) => { + const banner = (SUPPORT_TASKS.global_banner || []) + // hide the “Upgrade” action for premium users + .filter(t => isPremium || t.id !== "GN-07"); + + const pageTasks = SUPPORT_TASKS[page] || []; + return [...banner, ...pageTasks] + .map(t => `• ${t.label}`) + .join("\n"); +}; + 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.” +2. If the user chooses **Interest Inventory** + 1. Tell them to open the Interest Inventory from the top by Clicking "Find Your Career" then "Interest Inventory". + 2. Explain the answer keys (A = Agree, U = Unsure, D = Dislike); each click moves to the next question. + 3. Wait while the UI runs the survey (do **not** collect answers in chat). + 4. When the survey finishes, blue career tiles appear; tell them to click any tile to open its detail modal. +3. If the user chooses **Career Search** 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. + 2. When the suggestion list appears, they should select the desired career. + 3. A detail modal opens automatically — no blue tile in this flow. +4. After a modal is open, you can guide them to salary, projections, AI-risk, etc. `; const CAREER_EXPLORER_FEATURES = ` @@ -80,14 +94,15 @@ const CAREER_EXPLORER_FEATURES = ` • 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. + • “How do I plan education?” → tell them to click *Select for Education*. `; +const SUPPORT_TASKS = JSON.parse( + await fs.readFile( + path.join(repoRoot, "src", "ai", "agent_support_reference.json"), + "utf8" + ) +); /* ---------------------------------------------------------------------------- FACTORY: registers POST /api/chat/free on the passed-in Express app ----------------------------------------------------------------------------- */ @@ -127,43 +142,9 @@ export default function chatFreeEndpoint( } }, - /* 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", @@ -202,7 +183,17 @@ const UI_TOOLS = [ authenticateUser, async (req, res) => { try { - const { prompt = "", chatHistory = [], pageContext = "" } = req.body || {}; + res.writeHead(200, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + Connection : "keep-alive", + "X-Accel-Buffering": "no" + }); + res.flushHeaders?.(); + + const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); }; + + const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {}; if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" }); /* ---------- 0️⃣ FAQ fast-path ---------- */ @@ -220,20 +211,49 @@ const UI_TOOLS = [ /* --------------------------------------- */ const intent = classifyIntent(prompt); - let { system } = buildContext(req.user || {}, pageContext, intent); - if (pageContext === "CareerExplorer") { - system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES; - } + + /* ---------- system-prompt scaffold ---------- */ + let { system } = buildContext(req.user || {}, pageContext, intent); + + /* 1) Add master playbooks per page (optional) */ + if (pageContext === "CareerExplorer") { + system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES; + } + + /* 2) Append task catalogue so the bot can describe valid actions */ + const isPremium = req.user?.plan_type === "premium"; + system += + "\n\n### What the user can do on this screen\n" + + taskListForPage(pageContext, isPremium) + + "\n\n(Remember: do not click for the user; only explain the steps.)"; + + const modalPayload = snapshot?.modalCtx; + + if (modalPayload) { + const { + socCode = "n/a", + title = "n/a", + aiRisk = "n/a", + salary = {}, + projections = {}, + description = {}, + tasks = [] + } = modalPayload; + + system += + `\n\n### Current career in focus\n` + + `SOC: ${socCode} | Title: ${title}\n` + + `AI-risk: ${aiRisk}\n` + + `Median salary – regional: $${salary.regional ?? "n/a"}, ` + + `national: $${salary.national ?? "n/a"}\n` + + `Projections: ${JSON.stringify(projections)}` + + `Description: ${description}\n` + + `Key tasks: ${tasks.slice(0,5).join("; ")}\n`; + } /* ── 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 = [ + const tools = [...SUPPORT_TOOLS]; // guidance mode → support-only; + let messages = [ { role: "system", content: system }, ...chatHistory, { role: "user", content: prompt } @@ -244,70 +264,24 @@ const UI_TOOLS = [ stream : true, messages, tools, - tool_choice : tools.length ? "auto" : undefined }); - /* ── keep state while a tool call streams in ─────────────── */ - let pendingName = null; // addCareerToComparison - let pendingArgs = ""; // '{"socCode":"15-2051"}' +for await (const part of chatStream) { + const txt = part.choices?.[0]?.delta?.content; + if (txt) sendChunk(txt); + } - for await (const part of chatStream) { - const delta = part.choices?.[0]?.delta || {}; - - /* 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 - } - - /* 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 + // tell the front-end we are done and close the stream + res.write("\n"); +res.end(); + + + } catch (err) { 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 + } +); +} \ No newline at end of file diff --git a/backend/utils/fuzzyCareerLookup.js b/backend/utils/fuzzyCareerLookup.js new file mode 100644 index 0000000..7a3a33c --- /dev/null +++ b/backend/utils/fuzzyCareerLookup.js @@ -0,0 +1,23 @@ +import path from "path"; +import fs from "node:fs"; +import { fileURLToPath } from "url"; +import Fuse from "fuse.js"; + +/* resolve …/backend/utils → …/public/careers_with_ratings.json */ +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const careersPath = path.join(__dirname, "..", "..", "public", "careers_with_ratings.json"); + +const CAREERS = JSON.parse(fs.readFileSync(careersPath, "utf-8")); + +const fuse = new Fuse(CAREERS, { + keys : ["title"], + threshold : 0.30, + ignoreLocation: true, +}); + +export function fuzzyCareerLookup(label = "") { + if (!label.trim()) return null; + const [hit] = fuse.search(label.trim(), { limit: 1 }); + return hit?.item ?? null; +} diff --git a/src/App.js b/src/App.js index 1d439e8..c4fbcfd 100644 --- a/src/App.js +++ b/src/App.js @@ -33,6 +33,7 @@ import ResumeRewrite from './components/ResumeRewrite.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js'; import usePageContext from './utils/usePageContext.js'; import ChatDrawer from './components/ChatDrawer.js'; +import ChatCtx from './contexts/ChatCtx.js'; @@ -42,7 +43,7 @@ export const ProfileCtx = React.createContext(); function App() { const navigate = useNavigate(); const location = useLocation(); - const pageContext = usePageContext(); + const { pageContext, snapshot: routeSnapshot } = usePageContext(); /* ------------------------------------------ ChatDrawer – route-aware tool handlers @@ -73,6 +74,7 @@ const uiToolHandlers = useMemo(() => { // Auth states const [isAuthenticated, setIsAuthenticated] = useState(false); const [user, setUser] = useState(null); + const [chatSnapshot, setChatSnapshot] = useState(null); // Loading state while verifying token const [isLoading, setIsLoading] = useState(true); @@ -213,6 +215,7 @@ const uiToolHandlers = useMemo(() => { scenario, setScenario, user, }} > +
{/* Header */}
@@ -553,12 +556,14 @@ const uiToolHandlers = useMemo(() => { {/* Session Handler (Optional) */}
+
); } diff --git a/src/Root.js b/src/Root.js new file mode 100644 index 0000000..f742296 --- /dev/null +++ b/src/Root.js @@ -0,0 +1,12 @@ +// Root.jsx +import React from "react"; +import { PageFlagsProvider } from "./utils/PageFlagsContext"; +import App from "./App"; + +export default function Root() { + return ( + + + + ); +} diff --git a/src/ai/agent_support_reference.json b/src/ai/agent_support_reference.json new file mode 100644 index 0000000..9f8bc1f --- /dev/null +++ b/src/ai/agent_support_reference.json @@ -0,0 +1,46 @@ +{ + "global_banner": [ + { "id": "GN-01", "label": "Find Your Career tab (dropdown)" }, + { "id": "GN-01A", "label": "Career Explorer option in dropdown" }, + { "id": "GN-01B", "label": "Interest Inventory option in dropdown" }, + + { "id": "GN-02", "label": "Preparing & UpSkilling tab" }, + { "id": "GN-03", "label": "Enhancing Your Career tab" }, + { "id": "GN-04", "label": "Retirement Planning tab" }, + + { "id": "GN-05", "label": "Profile tab" }, + { "id": "GN-06", "label": "Logout link" }, + + { "id": "GN-07", "label": "Upgrade to Premium button (green, only for free users)" } + ], + "CareerExplorer": [ + { "id": "CE-01", "label": "Open Interest Inventory" }, + { "id": "CE-02", "label": "Search & auto-select career" }, + { "id": "CE-03", "label": "Open career modal" }, + { "id": "CE-04", "label": "Add to Comparison" }, + { "id": "CE-05", "label": "Interest & Meaning modal" }, + { "id": "CE-06", "label": "Remove from Comparison" }, + { "id": "CE-07", "label": "Plan Education/Skills" }, + { "id": "CE-08", "label": "Edit Career Priorities" }, + { "id": "CE-09", "label": "Save Career Priorities" }, + { "id": "CE-10", "label": "Filter by Prep Level" }, + { "id": "CE-11", "label": "Filter by Fit Level" }, + { "id": "CE-12", "label": "Reload Career Suggestions" }, + { "id": "CE-13", "label": "Legend (limited-data)" }, + { "id": "CE-P1", "label": "Career Priorities popup appears (first-time users)" }, + { "id": "CE-P2", "label": "Answer the six priority questions and click Save" }, + { "id": "CE-M1", "label": "Career detail modal is open" }, + { "id": "CE-M2", "label": "Scroll through the modal sections (AI-Risk banner → Job Description & Tasks → Salary Data → Economic Projections)" } + ], + "EducationalProgramsPage": [ + { "id": "EP-01", "label": "Search & select career" }, + { "id": "EP-02", "label": "Change Career button" }, + { "id": "EP-03", "label": "Knowledge/Skills/Abilities table" }, + { "id": "EP-04", "label": "Coursera / edX course links" }, + { "id": "EP-05", "label": "Sort dropdown (Tuition / Distance)" }, + { "id": "EP-06", "label": "Max Tuition filter" }, + { "id": "EP-07", "label": "Max Distance filter" }, + { "id": "EP-08", "label": "In-State Only checkbox" }, + { "id": "EP-09", "label": "Select School button" } + ] +} diff --git a/src/assets/botTools.json b/src/assets/botTools.json index 54bab63..d36f80d 100644 --- a/src/assets/botTools.json +++ b/src/assets/botTools.json @@ -19,6 +19,15 @@ ] } }, + { + "name": "resolveCareerTitle", + "description": "Convert a free-text career label to its canonical SOC record", + "parameters": { + "type": "object", + "properties": { "title": { "type": "string" } }, + "required": ["title"] + } +}, { "name": "getSchoolsForCIPs", "description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state", diff --git a/src/assets/pageToolMap.json b/src/assets/pageToolMap.json index 777e57f..e716d12 100644 --- a/src/assets/pageToolMap.json +++ b/src/assets/pageToolMap.json @@ -9,6 +9,7 @@ "getTuitionForCIPs" ], "CareerExplorer": [ + "resolveCareerTitle", "getEconomicProjections", "getSalaryData", "addCareerToComparison", diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 4fa01a4..0489f8a 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -1,12 +1,12 @@ import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; +import ChatCtx from '../contexts/ChatCtx.js'; import CareerSuggestions from './CareerSuggestions.js'; import CareerPrioritiesModal from './CareerPrioritiesModal.js'; import CareerModal from './CareerModal.js'; import InterestMeaningModal from './InterestMeaningModal.js'; import CareerSearch from './CareerSearch.js'; -import ChatDrawer from './ChatDrawer.js'; import { Button } from './ui/button.js'; import axios from 'axios'; @@ -72,6 +72,8 @@ function CareerExplorer() { const [error, setError] = useState(null); const [pendingCareerForModal, setPendingCareerForModal] = useState(null); + const { setChatSnapshot } = useContext(ChatCtx); + const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false); const [modalData, setModalData] = useState({ career: null, @@ -97,6 +99,43 @@ function CareerExplorer() { const [loading, setLoading] = useState(false); const [progress, setProgress] = useState(0); + + // Weighted "match score" logic. (unchanged) + const priorityWeight = (priority, response) => { + const weightMap = { + interests: { + 'I know my interests (completed inventory)': 5, + 'I’m not sure yet': 1, + }, + meaning: { + 'Yes, very important': 5, + 'Somewhat important': 3, + 'Not as important': 1, + }, + stability: { + 'Very important': 5, + 'Somewhat important': 3, + 'Not as important': 1, + }, + growth: { + 'Yes, very important': 5, + 'Somewhat important': 3, + 'Not as important': 1, + }, + balance: { + 'Yes, very important': 5, + 'Somewhat important': 3, + 'Not as important': 1, + }, + recognition: { + 'Very important': 5, + 'Somewhat important': 3, + 'Not as important': 1, + }, + }; + return weightMap[priority][response] || 1; + }; + const jobZoneLabels = { '1': 'Little or No Preparation', '2': 'Some Preparation Needed', @@ -494,6 +533,7 @@ function CareerExplorer() { code: obj.soc_code, title: obj.title, cipCode: obj.cip_code, + fromManualSearch: true }; console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted); handleCareerClick(adapted); @@ -543,6 +583,54 @@ function CareerExplorer() { 'recognition', ]; + + /* ---------- core context: sent every turn ---------- */ +const coreCtx = useMemo(() => { + // 1) Riasec scores + const riasecScores = userProfile?.riasec_scores + ? JSON.parse(userProfile.riasec_scores) + : null; + + // 2) priority weights normalised 0-1 + const priorityWeights = priorities ? { + stability : priorityWeight('stability' , priorities.stability) / 5, + growth : priorityWeight('growth' , priorities.growth) / 5, + balance : priorityWeight('balance' , priorities.balance) / 5, + recognition : priorityWeight('recognition', priorities.recognition)/ 5, + interests : priorityWeight('interests' , priorities.interests) / 5, + mission : priorityWeight('meaning' , priorities.meaning) / 5, + } : null; + + return { riasecScores, priorityWeights }; +}, [userProfile, priorities]); + +/* ---------- modal context: exists only while a modal is open ---------- */ +const modalCtx = useMemo(() => { + if (!selectedCareer || !careerDetails) return null; + + const medianRow = careerDetails.salaryData + ?.find(r => r.percentile === "Median"); + + return { + socCode : selectedCareer.code, + title : selectedCareer.title, + aiRisk : careerDetails.aiRisk?.riskLevel ?? "n/a", + salary : medianRow + ? { regional : medianRow.regionalSalary, + national : medianRow.nationalSalary } + : null, + projections : careerDetails.economicProjections ?? {}, + description : careerDetails.jobDescription, + tasks : careerDetails.tasks, + }; +}, [selectedCareer, careerDetails]); + + +useEffect(() => { + // send null when no modal is open → ChatDrawer simply omits it + setChatSnapshot({ coreCtx, modalCtx }); +}, [coreCtx, modalCtx, setChatSnapshot]); + const getCareerRatingsBySocCode = (socCode) => { return ( masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {} @@ -587,15 +675,18 @@ function CareerExplorer() { const masterRatings = getCareerRatingsBySocCode(career.code); // 2) figure out interest - const userHasInventory = priorities.interests !== "I’m not sure yet"; + const userHasInventory = + !career.fromManualSearch && // ← skip the shortcut if manual + priorities.interests && + priorities.interests !== "I’m not sure yet"; const defaultInterestValue = userHasInventory - ? // if user has done inventory, we rely on fit rating or fallback to .json + ? (fitRatingMap[career.fit] || masterRatings.interests || 3) - : // otherwise, just start them at 3 (we'll ask in the modal) + : 3; - // 3) always ask for meaning, start at 3 + const defaultMeaningValue = 3; // 4) open the InterestMeaningModal instead of using prompt() @@ -726,41 +817,7 @@ const handleSelectForEducation = (career) => { }); }, [careerSuggestions, selectedJobZone, selectedFit]); - // Weighted "match score" logic. (unchanged) - const priorityWeight = (priority, response) => { - const weightMap = { - interests: { - 'I know my interests (completed inventory)': 5, - 'I’m not sure yet': 1, - }, - meaning: { - 'Yes, very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - stability: { - 'Very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - growth: { - 'Yes, very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - balance: { - 'Yes, very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - recognition: { - 'Very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - }; - return weightMap[priority][response] || 1; - }; + useEffect(() => { /* ---------- add-to-comparison ---------- */ @@ -827,37 +884,6 @@ const handleSelectForEducation = (career) => { ); }; - /* ---------- Chat-assistant snapshot ---------- */ -const explorerSnapshot = useMemo(() => { - // 1) RIASEC scores (if the interest inventory has been done) - const riasecScores = userProfile?.riasec_scores - ? JSON.parse(userProfile.riasec_scores) // stored as JSON in DB - : null; - - // 2) User-selected career-priority weights, normalised to 0-1 - const priorityWeights = priorities - ? { - stability : priorityWeight('stability' , priorities.stability) / 5, - growth : priorityWeight('growth' , priorities.growth) / 5, - balance : priorityWeight('balance' , priorities.balance) / 5, - recognition : priorityWeight('recognition', priorities.recognition)/ 5, - interests : priorityWeight('interests' , priorities.interests) / 5, - mission : priorityWeight('meaning' , priorities.meaning) / 5, - } - : null; - - // 3) Matrix of careers currently on-screen (or in the comparison list, pick one) - const careerMatrix = filteredCareers.map(c => ({ - socCode : c.code, - careerName : c.title, - // quick-and-dirty match score; replace with your own if you’ve already got one - matchScore : typeof c.matchScore === 'number' - ? c.matchScore - : (fitRatingMap[c.fit] || 0) * 20 // e.g. “Best” → 100, “Good” → 60 - })); - - return { riasecScores, priorityWeights, careerMatrix }; -}, [userProfile, priorities, filteredCareers]); // ------------------------------------------------------ // Render @@ -885,7 +911,17 @@ const explorerSnapshot = useMemo(() => { /> +

Career Comparison

+ {/* quick-edit link */} + +
{careerList.length ? ( diff --git a/src/components/ChatDrawer.js b/src/components/ChatDrawer.js index ccca719..4a3c2f8 100644 --- a/src/components/ChatDrawer.js +++ b/src/components/ChatDrawer.js @@ -10,16 +10,13 @@ import { MessageCircle } from "lucide-react"; ----------------------------------------------------------------*/ export default function ChatDrawer({ pageContext = "Home", - snapshot = {}, - uiToolHandlers = {} // e.g. { addCareerToComparison, openCareerModal } + snapshot = null, }) { /* state */ const [open, setOpen] = useState(false); const [prompt, setPrompt] = useState(""); const [messages, setMessages] = useState([]); // { role, content } const listRef = useRef(null); - - console.log("CHATDRAWER-BUILD-TAG-2025-07-02"); /* auto-scroll */ useEffect(() => { @@ -76,67 +73,26 @@ export default function ChatDrawer({ const decoder = new TextDecoder(); let buf = ""; + /* ─────────────── STREAM LOOP ─────────────── */ while (true) { const { value, done } = await reader.read(); if (done) break; if (!value) continue; - const chunk = decoder.decode(value); - buf += decoder.decode(value); + buf += decoder.decode(value, { stream: true }); - /* 1️⃣ process every complete “__tool:” line in the buffer */ + let nl; + while ((nl = buf.indexOf("\n")) !== -1) { + const line = buf.slice(0, nl).trim(); // one full line + buf = buf.slice(nl + 1); // keep remainder - 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); + /* 2️⃣ normal assistant text */ + if (line) pushAssistant(line + "\n"); } } + /* ───────── END STREAM LOOP ───────── */ + + if (buf.trim()) pushAssistant(buf); } catch (err) { console.error("[ChatDrawer] stream error", err); pushAssistant("Sorry — something went wrong. Please try again later."); diff --git a/src/contexts/ChatCtx.js b/src/contexts/ChatCtx.js new file mode 100644 index 0000000..4df5048 --- /dev/null +++ b/src/contexts/ChatCtx.js @@ -0,0 +1,5 @@ +// src/contexts/ChatCtx.js +import { createContext } from 'react'; + +const ChatCtx = createContext({ setChatSnapshot: () => {} }); +export default ChatCtx; diff --git a/src/index.js b/src/index.js index 4051a1d..3f9813c 100644 --- a/src/index.js +++ b/src/index.js @@ -4,13 +4,16 @@ import './index.css'; import App from './App.js'; import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter import reportWebVitals from './reportWebVitals.js'; +import { PageFlagsProvider } from './utils/PageFlagsContext.js'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - {/* Wrap App with BrowserRouter */} - + + + + -); + ); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/src/utils/PageFlagsContext.js b/src/utils/PageFlagsContext.js new file mode 100644 index 0000000..860f966 --- /dev/null +++ b/src/utils/PageFlagsContext.js @@ -0,0 +1,13 @@ +// src/utils/PageFlagsContext.js +import React, { useState, createContext, useContext } from "react"; + +const PageFlagsCtx = createContext([{}, () => {}]); + +export const PageFlagsProvider = ({ children }) => { + const state = useState({}); // [flags, setFlags] + return ( + {children} + ); +}; + +export const usePageFlags = () => useContext(PageFlagsCtx); diff --git a/src/utils/usePageContext.js b/src/utils/usePageContext.js index 07609ef..6955666 100644 --- a/src/utils/usePageContext.js +++ b/src/utils/usePageContext.js @@ -1,8 +1,8 @@ // src/utils/usePageContext.js -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import { useLocation } from "react-router-dom"; -/* route → page-key map */ +/* -------- route → page map (unchanged) -------- */ const routeMap = [ { test: p => p.startsWith("/career-explorer"), page: "CareerExplorer" }, { test: p => p.startsWith("/educational-programs"), page: "EducationalProgramsPage" }, @@ -11,7 +11,8 @@ const routeMap = [ { test: p => p.startsWith("/resume-rewrite"), page: "ResumeRewrite" }, ]; -export default function usePageContext() { +export default function usePageContext(coreCtx = {}, modalCtx = null) { + /* -------- 1) figure out which page we’re on -------- */ const { pathname } = useLocation(); const [page, setPage] = useState("Home"); @@ -20,5 +21,13 @@ export default function usePageContext() { setPage(found ? found.page : "Home"); }, [pathname]); - return page; // ← hook now RETURNS the page string + /* -------- 2) memo-build one compact snapshot -------- */ + const snapshot = useMemo(() => { + const snap = { ...coreCtx }; // always-on stuff + if (modalCtx) snap.modalPayload = modalCtx; // only when defined + return snap; + }, [coreCtx, modalCtx]); + + /* You still get `page`, but now also `snapshot` */ + return { pageContext: page, snapshot }; } diff --git a/user_profile.db b/user_profile.db index 57ef8446d8ed8fbccbfeb86f7637a07a79527ec3..00f0fa0f9d13aefd47cd814409c428503dbc0b5a 100644 GIT binary patch delta 8614 zcmb7JU5q5xRi5dP!P{|9N3t!gNfzI>*SoQ&d!~P8Jf4Xov&?!OL#*9o>_h@qPIcAo z?z>Z6)vlkJZA(#QXV!>BLSltb^MHhr$Roo0fGm*U1$cr;_yGx|U_l;0KoCI@JcI}M zzH_Upd&X-EENiFh@80wKo$s9c&AQ z(Uto5ac`+p|L^*L)&HaZgZe+!zklz_Z46?&?eT@BGEX+3F`6%iZl(r`_qSx7%I0 z+-Y?>-JNyZ;$?5E)opirc-rRWR;RVy?(*f0{M)X*T+c5%?QR!OTjkSDzTD(xZ@bmA z8?DLHPAkLGeEe+rac2{k&&b<5t*!PBFF(i2&27lBNjWa(FXNIsU6QB0){d3q>HOQB zc8^al%F_;>c6j+z{w<{3#^n=F^18Ft>U2ep1~0dI)R=Fp_g-7Ay!-yASC+rCR9mV3 z?L$Ag^1>(ndigu|@7!C??zS#g8=br9+9SuN*W1Akvf)>>8)+voVW7SiMh7~I_rkzS z-9$z6Cytw#Lz5UCt0R+))G!LuNvuquCTZU{Zc|MnbLb?xslrGdhJG3(T1Uzm>cHh! zVU#VdH*j3z%X;hTdNgzb^Ojal;9;3?947c(ZrD_D>W-8XD03bgj@rg)?y9Ych0fcHGu!%MOFSdaTK+WIes{aYGVyDoojeknY<@(MN!AbD)! zL}f5mXjSD-b7kdfLEV08x1M0tFu)G7GYtY4@v~7n#nuoMi>C6;7>1_=X%f;7LA;K{*lM-6@xSVHcRQWk?#^BBscPf# zyI0mO+qzQ_t&V)HOEI*Zi+ufW0a1a>lq zTJ_T@bW~2F22MPpAn_N*D8&zQO3^DdCT~LgrRc0Lmee1)@Q#*gvF`#p$H1Y(M}8_H}xk;3h&#fv1%sM z30E92F5zI~Dt&u`cVZ|qd&jA4Y>jCqnmQRFUxp)fXrhEM;~-TWY7TEYskjlMA`Ouf zv-ZWxV;Pp6jHZZZyv;?CMm9k-(gPdd_I$V8&9V4d+{{?WZMv?$7#?Zp4B5Ry2kTgO z;SC08nEKoq39prhXn{n~K2u5Lc!(3(snjD1eX5MbRAR>Z0TI(V`Qnq+#^+BSTf1<4 z&=aH<;M&)Tx};vZJu-chsH-|Q!vMawKaj{;5>?yhvC1e%tPxdo#lZOECXDCl5Sgld zeQ0Q8>kNb(a7O}*sD=Bn4T?BPy(xSbnF&F{4TAw*6ZrCwFK!Y>xj*qJ2fSE25x^!{ z3tkJAV)h?A+WXLp8`&g`$8N;Qjg;OPh+hnN5l3#Sw&=Pym9&m$Xa-QJ$AQcbD=WgixPCtxt6f3iMkot%xo5~rKM=m5t zN2L0Pz0=-$`+B3=c=4CNymsMar#CMnd*OJJ(un)hIML%+T|t21XDojK%`re6k<(N< z7{VmEj(q?wvZ~CAjM?uYw3+41d&WL~_I<92r1TJnemJrA7*BAU02ZTMh~M~%3sHQ1 zn?^@n5pCmg4Wyz7h)9NKkaX4@%EWPsYfldVa;8a&f42ByzLKKbjZ)1fZM_rPw z3g$8M0L#V0D7?Q(x`Zkjricm5!l9eIXIzlk&Bl|F;33CZuXX5np8rM{mXyAuO zHe_RWgoN|8#bT?Yjaf+MPhoF?J8TFcQSSIRVuJWnLWe;h;Nv(PBu4<+dF7e?mRq@y zvE1gFZo`mSOPty(6gcZ;SY)a+s`eo~Y=e*rJDxL1Y=MAd0%)0pp>I)Ztdmjbg?>21 zcI)cOyq07ZQs5iyjDda{wH4K+3A9g?iOg!-&i%A5nI2<#{v~#u` zwKsP=+jpN;)yBr%Hvleg_U5z)isc4UH%#V-NqocLUkS6oIW$9NE)#O#o8c&-Ee7Vc z_F6}xrip}5C?rn+^vIn9WHu2Bc!b)8;uFI4Py&t_0n^=Z0$)S%uh^80X+uWpKv*)F*I@LcEiNX|cjO zKs8@F(nEaLVv!HTZTwY4cvRe?TH+RwfZ)~6aw8Ew`2IKy4A7CrDPx`HBN-AvKEpuZ zH*Xp^W0t@GH7*QniQ!tvvxp=RNNiC1Y@Q!z4fe<-$vo5s9kPI9l!G+~Nx4vCdaBdg z-P+mhY`(YhZiz&EXnHZdcX1+ zVSym2G;U5&Pr(p2BAXUo#Nxq+Fgz@yrXX@>OGp0U_$Q7BB9r2uHK#F4R-r3eGd|Ko{?2x&gBD`ySt_1JPI$S^TgAGJacY{{HN z?BR$L2p;rD+yNU595&R5NKXfpT|5WU0`hSaj2EsgwxOxuOKdnyjW;g{(qGCYQu1Tl zQ5rIDM@=w$;!@?OGB`Itu_`lJj0i=g^#tE5+KrsKl)@l>0=998Zv>WQoP-2d42gP) zc!M{kRTb)+H?X!Njlt%Lqvl1F{Bq4gtuq(_zqLCjx1Xst_D_}p5ii@mLMAUW|4)~n znw|TzP4S27(jRH z)^Z7S10WxqHY~FEj|g<6w%~^Fcm%=e>UY)vsC%0y$>*w#t>ZPc;ysRU@>$b9<`y6+ zTJ2M|3>_qBJA+4Jr+7#*me`^UA7T1pxkNuihlCqC04&Nqq){>1xD5R!6XX#eEif4L zUv2{bg8Fe9LNodfDkz}UJjynG93H~8c~6ITEXRl;3tqOxNWLnC!Z%1;h=<+*0hO1k zA_yU%6i)0@RuQHU7ZlCsI}v5dfro8^3Ti67(E^andtKpnB~KNadK3niF_eH)2s}bi zB`+-RfdObrB94pnmS2Er8{pO;ZW?2_f}s|`jTF7xzOp!5>GpOvw@10bd!x5YZ1sU7mBeE z0g-MR$Wl@`PT?pliKm{!hEU*{L54_3eGC%+KO_x^OagRf4*2|T=ed)g zdm4?`aR*j^qqkYkuCKD!e50689`!=%?%@Bn2; z9Ksq`j7T8bT(X?OoyrB9;v8{dB!gZNVL9Iw(@e~LkY3!GOj4$M6rUptSy^(SY4z7@y-y|8pLj^) z!v1&vSE7~d|tvG$B3?!PHxd~pmqpHpd-5BF$SPW#|}kw>~6hBbN)B&5u^(OJ=! z%spf{`tn@R8D_4}WtNprHkF2;vqMKIgA&9*1b{X79cR)@6t6o4eiTcYE6>pY2o|&)j(vlytw>El;!P zmDd3L=zmz!L8z%RydkqnN{GP4Q*_oGwV`hEIjoOimL0bhBP?5`;>+boIpa8VNLc2? z2Vc#VIMTk~0!#xFA8?|vuqhcV9FOVip#km#GxO>=o;b;9iXF-p({jhMo}1uj%5qs) zDTl2bwFtYg+ah?OEgKoku{g7Kg4rXNDh+7C)OCsN))X3$;R1_o`y*AX;F}^`%OxZk z_Y}l|Tmj;`$K&?7#uvVU)IbZm#lC=NcY9&T+0_kux`k0X4i!eB@7)0GbSd;0LwkD= zmcKH>1PfXIN-^*$)kdBSgbHDD&NP8TZMTeA1pzV>)Ki2ACIcVKfeLD2d&{z90;iBz zJc3iBLdba5?pHc12PVPZvp4#cjXdyBNx`YNPF~okHf$|D>dg;IucZ+}gPBlrkNuG| zVL226Vci*Ndj;6T0gLwF$uPpe1g?{VmqED-?r?ZG0-iQo2u@_l{2_cS{B2-JK)@<- zio}UONYBjq(ejh5rjMhVkpxMFu#;suv!nC>Pn2cRO7pGfi=K`?h?IA~6cikAJ^R=* z6gxy%Ky60l0>3=frat)aq_^97ez(1Od~^L=B(i!<^)rH&0k*7?p zaFW0yj6GBz_VEY+QH%K!iXFOeWW0n344wO|3H?-T?N>;M1&051>w5B3l759|Um z05Oq48MiSI0=o~F?Ee862p