AI agent context for CareerExplorer
This commit is contained in:
parent
58a8e15e09
commit
ec0ce1fce8
@ -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.
|
||||||
• “What’s a day in the life?” → tell them to open the modal’s *Overview* tab.
|
• “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*.
|
• “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 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 */
|
// 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
|
}
|
23
backend/utils/fuzzyCareerLookup.js
Normal file
23
backend/utils/fuzzyCareerLookup.js
Normal 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;
|
||||||
|
}
|
@ -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
12
src/Root.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
46
src/ai/agent_support_reference.json
Normal file
46
src/ai/agent_support_reference.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
@ -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",
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
"getTuitionForCIPs"
|
"getTuitionForCIPs"
|
||||||
],
|
],
|
||||||
"CareerExplorer": [
|
"CareerExplorer": [
|
||||||
|
"resolveCareerTitle",
|
||||||
"getEconomicProjections",
|
"getEconomicProjections",
|
||||||
"getSalaryData",
|
"getSalaryData",
|
||||||
"addCareerToComparison",
|
"addCareerToComparison",
|
||||||
|
@ -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,
|
||||||
|
'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 = {
|
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 !== "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 =
|
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,
|
|
||||||
'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(() => {
|
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 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
|
// 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>
|
||||||
|
@ -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 – 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) {
|
} 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
5
src/contexts/ChatCtx.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// src/contexts/ChatCtx.js
|
||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
const ChatCtx = createContext({ setChatSnapshot: () => {} });
|
||||||
|
export default ChatCtx;
|
@ -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))
|
||||||
|
13
src/utils/PageFlagsContext.js
Normal file
13
src/utils/PageFlagsContext.js
Normal 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);
|
@ -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 we’re 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 };
|
||||||
}
|
}
|
||||||
|
BIN
user_profile.db
BIN
user_profile.db
Binary file not shown.
Loading…
Reference in New Issue
Block a user