AI agent context for CareerExplorer

This commit is contained in:
Josh 2025-07-07 14:51:55 +00:00
parent 58a8e15e09
commit ec0ce1fce8
14 changed files with 348 additions and 256 deletions

View File

@ -4,6 +4,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { vectorSearch } from "./vectorSearch.js"; import { vectorSearch } from "./vectorSearch.js";
import { fuzzyCareerLookup } from "./fuzzyCareerLookup.js";
/* Resolve current directory ─────────────────────────────────────────────── */ /* Resolve current directory ─────────────────────────────────────────────── */
const __filename = fileURLToPath(import.meta.url); 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 = ` const INTEREST_PLAYBOOK = `
### When the user is on **CareerExplorer** and no career tiles are visible ### When the user is on **CareerExplorer** and no career tiles are visible
1. Explain there are two ways to begin: 1. Explain there are two ways to begin:
Interest Inventory (7-minute, 60-question survey) Interest Inventory (7-minute, 60-question survey)
Manual search (type a career in the Search for Career bar) Manual search (type a career in the Search for Career bar)
2. If the user chooses **Inventory** 2. If the user chooses **Interest Inventory**
1. Tell them to click the green **Start Interest Inventory** button at the top of the page. 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) and that each click advances to the next question. 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 inside chat.** 3. Wait while the UI runs the survey (do **not** collect answers in chat).
4. When career tiles appear, say: Great! Your matches are listed below. Click any blue tile for details. 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** 3. If the user chooses **Manual search**
1. Tell them to click the **search bar** and type at least three letters. 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. 2. When the suggestion list appears, they should select the desired career.
4. **Never call \`getONetInterestQuestions\` or \`submitInterestInventory\` yourself.** 3. A detail modal opens automatically no blue tile in this flow.
5. After tiles appear, you may call salary, projection, skills, or other data tools to answer follow-up questions. 4. After a modal is open, you can guide them to salary, projections, AI-risk, etc.
`; `;
const CAREER_EXPLORER_FEATURES = ` const CAREER_EXPLORER_FEATURES = `
@ -81,13 +95,14 @@ const CAREER_EXPLORER_FEATURES = `
Which is better? tell them to add both careers and open the comparison table. 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. 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*. 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.
`; `;
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 FACTORY: registers POST /api/chat/free on the passed-in Express app
----------------------------------------------------------------------------- */ ----------------------------------------------------------------------------- */
@ -127,42 +142,8 @@ 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 = [ const SUPPORT_TOOLS = [
{ {
@ -202,7 +183,17 @@ const UI_TOOLS = [
authenticateUser, authenticateUser,
async (req, res) => { async (req, res) => {
try { 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" }); if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" });
/* ---------- 0⃣ FAQ fast-path ---------- */ /* ---------- 0⃣ FAQ fast-path ---------- */
@ -220,20 +211,49 @@ const UI_TOOLS = [
/* --------------------------------------- */ /* --------------------------------------- */
const intent = classifyIntent(prompt); const intent = classifyIntent(prompt);
/* ---------- system-prompt scaffold ---------- */
let { system } = buildContext(req.user || {}, pageContext, intent); let { system } = buildContext(req.user || {}, pageContext, intent);
/* 1) Add master playbooks per page (optional) */
if (pageContext === "CareerExplorer") { if (pageContext === "CareerExplorer") {
system += INTEREST_PLAYBOOK + CAREER_EXPLORER_FEATURES; 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 ────────────────────── */ /* ── Build tool list for this request ────────────────────── */
let tools = intent === "support" ? [...SUPPORT_TOOLS] : []; const tools = [...SUPPORT_TOOLS]; // guidance mode → support-only;
const uiNamesForPage = PAGE_TOOLMAP[pageContext] || []; let messages = [
for (const def of BOT_TOOLS) {
if (uiNamesForPage.includes(def.name)) {
tools.push({ type: "function", function: def });
}
}
const messages = [
{ role: "system", content: system }, { role: "system", content: system },
...chatHistory, ...chatHistory,
{ role: "user", content: prompt } { role: "user", content: prompt }
@ -244,70 +264,24 @@ const UI_TOOLS = [
stream : true, stream : true,
messages, messages,
tools, tools,
tool_choice : tools.length ? "auto" : undefined
}); });
/* ── keep state while a tool call streams in ─────────────── */ for await (const part of chatStream) {
let pendingName = null; // addCareerToComparison const txt = part.choices?.[0]?.delta?.content;
let pendingArgs = ""; // '{"socCode":"15-2051"}' 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 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
} }
/* 2⃣ normal text tokens */ // tell the front-end we are done and close the stream
if (!delta.tool_calls && delta.content) { res.write("\n");
res.write(delta.content); // SSE-safe res.end();
}
} // ← closes the for-await loop
res.end(); // finished without tools
} catch (err) { // ← closes the try block above } catch (err) {
console.error("/api/chat/free error:", err); console.error("/api/chat/free error:", err);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
} // ← closes the async (req,res) => { … } }
); // ← closes app.post(…) );
} // ← closes export default chatFreeEndpoint }

View File

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

View File

@ -33,6 +33,7 @@ import ResumeRewrite from './components/ResumeRewrite.js';
import LoanRepaymentPage from './components/LoanRepaymentPage.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js';
import usePageContext from './utils/usePageContext.js'; import usePageContext from './utils/usePageContext.js';
import ChatDrawer from './components/ChatDrawer.js'; import ChatDrawer from './components/ChatDrawer.js';
import ChatCtx from './contexts/ChatCtx.js';
@ -42,7 +43,7 @@ export const ProfileCtx = React.createContext();
function App() { function App() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const pageContext = usePageContext(); const { pageContext, snapshot: routeSnapshot } = usePageContext();
/* ------------------------------------------ /* ------------------------------------------
ChatDrawer route-aware tool handlers ChatDrawer route-aware tool handlers
@ -73,6 +74,7 @@ const uiToolHandlers = useMemo(() => {
// Auth states // Auth states
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [chatSnapshot, setChatSnapshot] = useState(null);
// Loading state while verifying token // Loading state while verifying token
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -213,6 +215,7 @@ const uiToolHandlers = useMemo(() => {
scenario, setScenario, scenario, setScenario,
user, }} user, }}
> >
<ChatCtx.Provider value={{ setChatSnapshot }}>
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-800"> <div className="flex min-h-screen flex-col bg-gray-50 text-gray-800">
{/* Header */} {/* Header */}
<header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative"> <header className="flex items-center justify-between border-b bg-white px-6 py-4 shadow-sm relative">
@ -553,12 +556,14 @@ const uiToolHandlers = useMemo(() => {
<ChatDrawer <ChatDrawer
pageContext={pageContext} pageContext={pageContext}
snapshot={chatSnapshot}
uiToolHandlers={uiToolHandlers} uiToolHandlers={uiToolHandlers}
/> />
{/* Session Handler (Optional) */} {/* Session Handler (Optional) */}
<SessionExpiredHandler /> <SessionExpiredHandler />
</div> </div>
</ChatCtx.Provider>
</ProfileCtx.Provider> </ProfileCtx.Provider>
); );
} }

12
src/Root.js Normal file
View File

@ -0,0 +1,12 @@
// Root.jsx
import React from "react";
import { PageFlagsProvider } from "./utils/PageFlagsContext";
import App from "./App";
export default function Root() {
return (
<PageFlagsProvider>
<App />
</PageFlagsProvider>
);
}

View File

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

View File

@ -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", "name": "getSchoolsForCIPs",
"description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state", "description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state",

View File

@ -9,6 +9,7 @@
"getTuitionForCIPs" "getTuitionForCIPs"
], ],
"CareerExplorer": [ "CareerExplorer": [
"resolveCareerTitle",
"getEconomicProjections", "getEconomicProjections",
"getSalaryData", "getSalaryData",
"addCareerToComparison", "addCareerToComparison",

View File

@ -1,12 +1,12 @@
import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react'; import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import ChatCtx from '../contexts/ChatCtx.js';
import CareerSuggestions from './CareerSuggestions.js'; import CareerSuggestions from './CareerSuggestions.js';
import CareerPrioritiesModal from './CareerPrioritiesModal.js'; import CareerPrioritiesModal from './CareerPrioritiesModal.js';
import CareerModal from './CareerModal.js'; import CareerModal from './CareerModal.js';
import InterestMeaningModal from './InterestMeaningModal.js'; import InterestMeaningModal from './InterestMeaningModal.js';
import CareerSearch from './CareerSearch.js'; import CareerSearch from './CareerSearch.js';
import ChatDrawer from './ChatDrawer.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import axios from 'axios'; import axios from 'axios';
@ -72,6 +72,8 @@ function CareerExplorer() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null); const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const { setChatSnapshot } = useContext(ChatCtx);
const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false); const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false);
const [modalData, setModalData] = useState({ const [modalData, setModalData] = useState({
career: null, career: null,
@ -97,6 +99,43 @@ function CareerExplorer() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
// Weighted "match score" logic. (unchanged)
const priorityWeight = (priority, response) => {
const weightMap = {
interests: {
'I know my interests (completed inventory)': 5,
'Im 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 = { const jobZoneLabels = {
'1': 'Little or No Preparation', '1': 'Little or No Preparation',
'2': 'Some Preparation Needed', '2': 'Some Preparation Needed',
@ -494,6 +533,7 @@ function CareerExplorer() {
code: obj.soc_code, code: obj.soc_code,
title: obj.title, title: obj.title,
cipCode: obj.cip_code, cipCode: obj.cip_code,
fromManualSearch: true
}; };
console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted); console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted);
handleCareerClick(adapted); handleCareerClick(adapted);
@ -543,6 +583,54 @@ function CareerExplorer() {
'recognition', '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) => { const getCareerRatingsBySocCode = (socCode) => {
return ( return (
masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {} masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {}
@ -587,15 +675,18 @@ function CareerExplorer() {
const masterRatings = getCareerRatingsBySocCode(career.code); const masterRatings = getCareerRatingsBySocCode(career.code);
// 2) figure out interest // 2) figure out interest
const userHasInventory = priorities.interests !== "Im not sure yet"; const userHasInventory =
!career.fromManualSearch && // ← skip the shortcut if manual
priorities.interests &&
priorities.interests !== "Im not sure yet";
const defaultInterestValue = const defaultInterestValue =
userHasInventory userHasInventory
? // if user has done inventory, we rely on fit rating or fallback to .json ?
(fitRatingMap[career.fit] || masterRatings.interests || 3) (fitRatingMap[career.fit] || masterRatings.interests || 3)
: // otherwise, just start them at 3 (we'll ask in the modal) :
3; 3;
// 3) always ask for meaning, start at 3
const defaultMeaningValue = 3; const defaultMeaningValue = 3;
// 4) open the InterestMeaningModal instead of using prompt() // 4) open the InterestMeaningModal instead of using prompt()
@ -726,41 +817,7 @@ const handleSelectForEducation = (career) => {
}); });
}, [careerSuggestions, selectedJobZone, selectedFit]); }, [careerSuggestions, selectedJobZone, selectedFit]);
// Weighted "match score" logic. (unchanged)
const priorityWeight = (priority, response) => {
const weightMap = {
interests: {
'I know my interests (completed inventory)': 5,
'Im 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(() => { useEffect(() => {
/* ---------- add-to-comparison ---------- */ /* ---------- 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 youve 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 // Render
@ -885,7 +911,17 @@ const explorerSnapshot = useMemo(() => {
/> />
</div> </div>
<div className="flex items-baseline mb-4 gap-2">
<h2 className="text-xl font-semibold mb-4">Career Comparison</h2> <h2 className="text-xl font-semibold mb-4">Career Comparison</h2>
{/* quick-edit link */}
<button
type="button"
onClick={() => setShowModal(true)} // ← re-uses existing modal state
className="text-blue-600 underline text-sm focus:outline-none"
>
Edit priorities
</button>
</div>
{careerList.length ? ( {careerList.length ? (
<table className="w-full mb-4"> <table className="w-full mb-4">
<thead> <thead>

View File

@ -10,8 +10,7 @@ import { MessageCircle } from "lucide-react";
----------------------------------------------------------------*/ ----------------------------------------------------------------*/
export default function ChatDrawer({ export default function ChatDrawer({
pageContext = "Home", pageContext = "Home",
snapshot = {}, snapshot = null,
uiToolHandlers = {} // e.g. { addCareerToComparison, openCareerModal }
}) { }) {
/* state */ /* state */
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -19,8 +18,6 @@ export default function ChatDrawer({
const [messages, setMessages] = useState([]); // { role, content } const [messages, setMessages] = useState([]); // { role, content }
const listRef = useRef(null); const listRef = useRef(null);
console.log("CHATDRAWER-BUILD-TAG-2025-07-02");
/* auto-scroll */ /* auto-scroll */
useEffect(() => { useEffect(() => {
listRef.current && listRef.current &&
@ -76,67 +73,26 @@ export default function ChatDrawer({
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ""; let buf = "";
/* ─────────────── STREAM LOOP ─────────────── */
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
if (!value) continue; if (!value) continue;
const chunk = decoder.decode(value); buf += decoder.decode(value, { stream: true });
buf += decoder.decode(value);
/* 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 /* 2⃣ normal assistant text */
const line = lineRaw.trim(); if (line) pushAssistant(line + "\n");
if (!line.startsWith("__tool:")) continue; }
}
/* ───────── END STREAM LOOP ───────── */
const firstColon = line.indexOf(":", 7); if (buf.trim()) pushAssistant(buf);
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) { } catch (err) {
console.error("[ChatDrawer] stream error", err); console.error("[ChatDrawer] stream error", err);
pushAssistant("Sorry — something went wrong. Please try again later."); pushAssistant("Sorry — something went wrong. Please try again later.");

5
src/contexts/ChatCtx.js Normal file
View File

@ -0,0 +1,5 @@
// src/contexts/ChatCtx.js
import { createContext } from 'react';
const ChatCtx = createContext({ setChatSnapshot: () => {} });
export default ChatCtx;

View File

@ -4,13 +4,16 @@ import './index.css';
import App from './App.js'; import App from './App.js';
import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter import { BrowserRouter } from 'react-router-dom'; // Import BrowserRouter
import reportWebVitals from './reportWebVitals.js'; import reportWebVitals from './reportWebVitals.js';
import { PageFlagsProvider } from './utils/PageFlagsContext.js';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<BrowserRouter> {/* Wrap App with BrowserRouter */} <BrowserRouter>
<PageFlagsProvider>
<App /> <App />
</PageFlagsProvider>
</BrowserRouter> </BrowserRouter>
); );
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log)) // to log results (for example: reportWebVitals(console.log))

View File

@ -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 (
<PageFlagsCtx.Provider value={state}>{children}</PageFlagsCtx.Provider>
);
};
export const usePageFlags = () => useContext(PageFlagsCtx);

View File

@ -1,8 +1,8 @@
// src/utils/usePageContext.js // src/utils/usePageContext.js
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
/* route → page-key map */ /* -------- route → page map (unchanged) -------- */
const routeMap = [ const routeMap = [
{ test: p => p.startsWith("/career-explorer"), page: "CareerExplorer" }, { test: p => p.startsWith("/career-explorer"), page: "CareerExplorer" },
{ test: p => p.startsWith("/educational-programs"), page: "EducationalProgramsPage" }, { test: p => p.startsWith("/educational-programs"), page: "EducationalProgramsPage" },
@ -11,7 +11,8 @@ const routeMap = [
{ test: p => p.startsWith("/resume-rewrite"), page: "ResumeRewrite" }, { test: p => p.startsWith("/resume-rewrite"), page: "ResumeRewrite" },
]; ];
export default function usePageContext() { export default function usePageContext(coreCtx = {}, modalCtx = null) {
/* -------- 1) figure out which page were on -------- */
const { pathname } = useLocation(); const { pathname } = useLocation();
const [page, setPage] = useState("Home"); const [page, setPage] = useState("Home");
@ -20,5 +21,13 @@ export default function usePageContext() {
setPage(found ? found.page : "Home"); setPage(found ? found.page : "Home");
}, [pathname]); }, [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 };
} }

Binary file not shown.