Added CareerCoach - adjusted CareerRoadmap.

This commit is contained in:
Josh 2025-06-06 15:40:06 +00:00
parent ae3bfaadd1
commit 765492b856
7 changed files with 708 additions and 134 deletions

View File

@ -12,6 +12,7 @@ DB_USER=sqluser
DB_NAME=user_profile_db
DB_PASSWORD=ps<g+2DO-eTb2mb5
APTIVA_API_BASE=https://dev1.aptivaai.com/api
REACT_APP_API_URL=https://dev1.aptivaai.com/api
REACT_APP_ENV=production
REACT_APP_OPENAI_API_KEY=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

View File

@ -30,6 +30,7 @@ const app = express();
const PORT = process.env.PREMIUM_PORT || 5002;
const { getDocument } = pkg;
// 1) Create a MySQL pool using your environment variables
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
@ -364,14 +365,22 @@ app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res)
// 4) Construct ChatGPT messages
const messages = [
{
role: 'system',
content: `
{
role: 'system',
content: `
You are an expert career & financial coach.
Today's date: ${isoToday}.
Short-term means any date up to ${isoShortTermLimit} (within 6 months).
Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years).
All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words.
IMPORTANT RESTRICTIONS:
- NEVER suggest specific investments in cryptocurrency, stocks, or other speculative financial instruments.
- NEVER provide specific investment advice without appropriate risk disclosures.
- NEVER provide legal, medical, or psychological advice.
- ALWAYS promote responsible and low-risk financial planning strategies.
- Emphasize skills enhancement, networking, and education as primary pathways to financial success.
Respond ONLY in the requested JSON format.`
},
{
@ -380,7 +389,7 @@ Respond ONLY in the requested JSON format.`
Here is the user's current situation:
${summaryText}
Please provide exactly 3 short-term (within 6 months) and 2 long-term (13 years) milestones. Avoid any previously suggested milestones.
Please provide exactly 2 short-term (within 6 months) and 1 long-term (13 years) milestones. Avoid any previously suggested milestones.
Each milestone must have:
- "title" (up to 5 words)
- "date" in YYYY-MM-DD format (>= ${isoToday})
@ -429,11 +438,9 @@ function buildUserSummary({
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {}
collegeProfile = {},
aiRisk = null
}) {
// Provide a short multiline string about the user's finances, goals, etc.
// but avoid referencing scenarioRow.start_date
// e.g.:
const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`;
const careerName = scenarioRow.career_name || 'Unknown';
const careerGoals = scenarioRow.career_goals || 'No goals specified';
@ -446,7 +453,13 @@ function buildUserSummary({
const retirementSavings = financialProfile.retirement_savings || 0;
const emergencyFund = financialProfile.emergency_fund || 0;
// And similarly for collegeProfile if needed, ignoring start_date
let riskText = '';
if (aiRisk?.riskLevel) {
riskText = `
AI Automation Risk: ${aiRisk.riskLevel}
Reasoning: ${aiRisk.reasoning}`;
}
return `
User Location: ${location}
Career Name: ${careerName}
@ -460,9 +473,317 @@ Financial:
- Monthly Debt: \$${monthlyDebt}
- Retirement Savings: \$${retirementSavings}
- Emergency Fund: \$${emergencyFund}
${riskText}
`.trim();
}
// Example: ai/chat with correct milestone-saving logic
app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
try {
const {
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {},
chatHistory = []
} = req.body;
// ------------------------------------------------
// 1. Helper Functions
// ------------------------------------------------
// A. Build a "where you are now" vs. "where you want to go" message
function buildStatusSituationMessage(status, situation, careerName) {
const sStatus = (status || "").toLowerCase(); // e.g. "planned", "current", "exploring"
const sSituation = (situation || "").toLowerCase(); // e.g. "planning", "preparing", "enhancing", "retirement"
// "Where you are now"
let nowPart = "";
switch (sStatus) {
case "planned":
nowPart = `It appears you're looking ahead to a possible future in ${careerName}.`;
break;
case "current":
nowPart = `It appears you're already working in the ${careerName} field.`;
break;
case "exploring":
nowPart = `It appears you're exploring how ${careerName} might fit your future plans.`;
break;
default:
nowPart = `I dont have a clear picture of where you stand currently with ${careerName}.`;
break;
}
// "Where youd like to go next"
let nextPart = "";
switch (sSituation) {
case "planning":
nextPart = `You're aiming to clarify your strategy for moving into this field.`;
break;
case "preparing":
nextPart = `You're actively developing the skills you need to step into ${careerName}.`;
break;
case "enhancing":
nextPart = `Youd like to deepen or expand your responsibilities within ${careerName}.`;
break;
case "retirement":
nextPart = `You're considering how to transition toward retirement in this role.`;
break;
default:
nextPart = `I'm not entirely sure of your next direction.`;
break;
}
const combinedDescription = `${nowPart} ${nextPart}`.trim();
// Add a friendly note about how there's no "wrong" answer
const friendlyNote = `
No worries if these selections feel a bit overlapping or if you're just exploring.
One portion highlights where you currently see yourself, and the other points to where you'd like to go.
Feel free to refine these whenever you want, or just continue as is.
`.trim();
return `${combinedDescription}\n\n${friendlyNote}`;
}
// B. Build a user summary that references all available info
function buildUserSummary({ userProfile, scenarioRow, financialProfile, collegeProfile, aiRisk }) {
// For illustration; adjust to your actual data fields.
const userName = userProfile.first_name || "N/A";
const location = userProfile.location || "N/A";
const userGoals = userProfile.goals || []; // maybe an array
const careerName = scenarioRow?.career_name || "this career";
const socCode = scenarioRow?.soc_code || "N/A";
const jobDescription = scenarioRow?.job_description || "No description";
const tasksList = scenarioRow?.tasks?.length
? scenarioRow.tasks.join(", ")
: "No tasks info";
const income = financialProfile?.income ? `$${financialProfile.income}` : "N/A";
const debt = financialProfile?.debt ? `$${financialProfile.debt}` : "N/A";
const savings = financialProfile?.savings ? `$${financialProfile.savings}` : "N/A";
const major = collegeProfile?.major || "N/A";
const creditsCompleted = collegeProfile?.credits_completed || 0;
const graduationDate = collegeProfile?.expected_graduation || "Unknown";
const aiRiskReport = aiRisk?.report || "No AI risk info provided.";
return `
[USER PROFILE]
- Name: ${userName}
- Location: ${location}
- Goals: ${userGoals.length ? userGoals.join(", ") : "Not specified"}
[TARGET CAREER]
- Career Name: ${careerName}
- SOC Code: ${socCode}
- Job Description: ${jobDescription}
- Typical Tasks: ${tasksList}
[FINANCIAL PROFILE]
- Income: ${income}
- Debt: ${debt}
- Savings: ${savings}
[COLLEGE / EDUCATION]
- Major: ${major}
- Credits Completed: ${creditsCompleted}
- Expected Graduation Date: ${graduationDate}
[AI RISK ANALYSIS]
${aiRiskReport}
`.trim();
}
// Example environment config
const MILESTONE_API_URL = process.env.APTIVA_INTERNAL_API
? `${process.env.APTIVA_INTERNAL_API}/premium/milestone`
: "http://localhost:5002/api/premium/milestone";
const IMPACT_API_URL = process.env.APTIVA_INTERNAL_API
? `${process.env.APTIVA_INTERNAL_API}/premium/milestone-impacts`
: "http://localhost:5002/api/premium/milestone-impacts";
// ------------------------------------------------
// 2. AI Risk Fetch
// ------------------------------------------------
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
let aiRisk = null;
try {
const aiRiskRes = await fetch(`${apiBase}/premium/ai-risk-analysis`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
socCode: scenarioRow?.soc_code,
careerName: scenarioRow?.career_name,
jobDescription: scenarioRow?.job_description,
tasks: scenarioRow?.tasks || []
})
});
if (aiRiskRes.ok) {
aiRisk = await aiRiskRes.json();
} else {
console.warn("AI risk fetch failed with status:", aiRiskRes.status);
}
} catch (err) {
console.error("Error fetching AI risk analysis:", err);
}
// ------------------------------------------------
// 3. Build Status + Situation text
// ------------------------------------------------
const { status: userStatus } = scenarioRow;
const { career_situation: userSituation } = userProfile;
const careerName = scenarioRow?.career_name || "this career";
const combinedStatusSituation = buildStatusSituationMessage(
userStatus,
userSituation,
careerName
);
// ------------------------------------------------
// 4. Build Additional Context Summary
// ------------------------------------------------
const summaryText = buildUserSummary({
userProfile,
scenarioRow,
financialProfile,
collegeProfile,
aiRisk
});
// ------------------------------------------------
// 5. Construct System-Level Prompts
// ------------------------------------------------
const systemPromptIntro = `
You are Jess, a professional career coach working inside AptivaAI.
The user has already provided detailed information about their situation, career goals, finances, education, and more.
Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice.
Do not re-ask for the details below unless you need clarifications.
Reflect and use the user's actual data. Avoid purely generic responses.
`.trim();
const systemPromptStatusSituation = `
[CURRENT AND NEXT STEP OVERVIEW]
${combinedStatusSituation}
`.trim();
const systemPromptDetailedContext = `
[DETAILED USER PROFILE & CONTEXT]
${summaryText}
`.trim();
// Build up the final messages array
const messagesToSend = [
{ role: "system", content: systemPromptIntro },
{ role: "system", content: systemPromptStatusSituation },
{ role: "system", content: systemPromptDetailedContext },
...chatHistory // includes user and assistant messages so far
];
// ------------------------------------------------
// 6. Call GPT
// ------------------------------------------------
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({
model: "gpt-4",
messages: messagesToSend,
temperature: 0.7,
max_tokens: 600
});
let coachResponse = completion?.choices?.[0]?.message?.content?.trim();
// ------------------------------------------------
// 7. Detect and Possibly Save Milestones
// ------------------------------------------------
let milestones = [];
let isMilestoneFormat = false;
try {
milestones = JSON.parse(coachResponse);
isMilestoneFormat = Array.isArray(milestones);
} catch (e) {
isMilestoneFormat = false;
}
if (isMilestoneFormat && milestones.length) {
// 7a. Prepare data for milestone creation
const rawForMilestonesEndpoint = milestones.map(m => {
return {
title: m.title,
description: m.description,
date: m.date,
career_profile_id: scenarioRow?.id
};
});
// 7b. Bulk-create milestones
const mileRes = await fetch(MILESTONE_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ milestones: rawForMilestonesEndpoint })
});
if (!mileRes.ok) {
console.error("Failed to save milestones =>", mileRes.status);
coachResponse = "I prepared milestones, but couldn't save them right now. Please try again later.";
} else {
// 7c. newly created milestones with IDs
const createdMils = await mileRes.json();
// 7d. For each milestone, if it has "impacts", create them
for (let i = 0; i < milestones.length; i++) {
const originalMilestone = milestones[i];
const newMilestone = createdMils[i];
if (Array.isArray(originalMilestone.impacts) && originalMilestone.impacts.length) {
for (const imp of originalMilestone.impacts) {
const impactPayload = {
milestone_id: newMilestone.id,
impact_type: imp.impact_type,
direction: imp.direction || 'subtract',
amount: imp.amount || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
try {
const impactRes = await fetch(IMPACT_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impactPayload)
});
if (!impactRes.ok) {
console.error(`Failed to create impact for milestone ${newMilestone.id}`);
}
} catch (err) {
console.error(`Error creating impact for milestone ${newMilestone.id}`, err);
}
}
}
}
coachResponse = "I've created some actionable milestones for you (with financial impacts). You can view them in your roadmap!";
}
}
// ------------------------------------------------
// 8. Send JSON Response
// ------------------------------------------------
res.json({ reply: coachResponse });
} catch (err) {
console.error("Error in /api/premium/ai/chat =>", err);
res.status(500).json({ error: "Failed to generate conversational response." });
}
});
/***************************************************
AI MILESTONE CONVERSION ENDPOINT
****************************************************/

View File

@ -257,7 +257,7 @@ function App() {
to="/career-roadmap"
className="block px-4 py-2 hover:bg-gray-100 text-sm text-gray-700"
>
Career Roadmap
Roadmap & AI Career Coach
</Link>
<Link
to="/resume-optimizer"

View File

@ -0,0 +1,275 @@
import React, { useState, useEffect, useRef } from "react";
import authFetch from "../utils/authFetch.js";
export default function CareerCoach({
userProfile,
financialProfile,
scenarioRow,
collegeProfile,
onMilestonesCreated,
}) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const chatContainerRef = useRef(null);
const [hasSentMessage, setHasSentMessage] = useState(false);
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [messages, hasSentMessage]);
useEffect(() => {
const introMessage = generatePersonalizedIntro();
setMessages([introMessage]);
}, [scenarioRow, userProfile]);
function buildStatusSituationMessage(status, situation, careerName) {
const sStatus = (status || "").toLowerCase();
// e.g. "planned", "current", "exploring", etc.
const sSituation = (situation || "").toLowerCase();
// e.g. "planning", "preparing", "enhancing", "retirement"
// -----------------------------
// "Where you are right now"
// -----------------------------
let nowPart = "";
switch (sStatus) {
case "planned":
nowPart = `It sounds like you're looking ahead to a possible future in ${careerName}.`;
break;
case "current":
nowPart = `It sounds like you're already in the ${careerName} field.`;
break;
case "exploring":
nowPart = `It sounds like you're still exploring how ${careerName} might fit your plans.`;
break;
default:
nowPart = `I dont have much info on your current involvement with ${careerName}.`;
break;
}
// -----------------------------
// "Where youd like to go next"
// -----------------------------
let nextPart = "";
switch (sSituation) {
case "planning":
nextPart = `You're aiming to figure out your strategy for moving into this field.`;
break;
case "preparing":
nextPart = `You're actively developing the skills you need to step into ${careerName}.`;
break;
case "enhancing":
nextPart = `Youd like to deepen or broaden your responsibilities within ${careerName}.`;
break;
case "retirement":
nextPart = `You're contemplating how to transition toward retirement in this field.`;
break;
default:
nextPart = `I'm not entirely sure of your next direction.`;
break;
}
// -----------------------------
// Combine the descriptive text
// -----------------------------
const combinedDescription = `${nowPart} ${nextPart}`.trim();
// -----------------------------
// Optional “friendly note” if they seem to span different phases
// but we do *not* treat it as a mismatch or error.
// -----------------------------
let friendlyNote = `
Feel free to use AptivaAI however it best suits youtheres no "wrong" answer.
One part highlights your current situation, the other indicates what you're aiming for next.
Some folks aren't working and want premium-level guidance, and that's where we shine.
We can refine details anytime or just jump straight to what you're most interested in exploring now!
`.trim();
// You could conditionally show the friendlyNote only if you detect certain combos,
// OR you can show it unconditionally to make the user comfortable.
// For maximum inclusivity, we can always show it.
return `${combinedDescription}\n\n${friendlyNote}`;
}
const generatePersonalizedIntro = () => {
const careerName = scenarioRow?.career_name || null;
const goalsText = scenarioRow?.career_goals?.trim() || null;
const riskLevel = scenarioRow?.riskLevel;
const riskReasoning = scenarioRow?.riskReasoning;
const userSituation = userProfile?.career_situation?.toLowerCase();
const userStatus = scenarioRow?.status?.toLowerCase();
const combinedMessage = buildStatusSituationMessage(userStatus, userSituation, careerName);
const interestInventoryMessage = userProfile?.riasec
? `With your Interest Inventory profile (${userProfile.riasec}), I can tailor suggestions more precisely.`
: `If you complete the Interest Inventory, Ill be able to offer more targeted suggestions based on your interests.`;
const riskMessage =
riskLevel && riskReasoning
? `Note: This role has a <strong>${riskLevel}</strong> automation risk over the next 10 years. ${riskReasoning}`
: "";
const goalsMessage = goalsText
? `Your goals include:<br />${goalsText
.split(/\d+\.\s?/)
.filter(Boolean)
.map((goal) => `${goal.trim()}`)
.join("<br />")}`
: null;
const missingProfileFields = [];
if (!careerName) missingProfileFields.push("career choice");
if (!goalsText) missingProfileFields.push("career goals");
if (!userSituation) missingProfileFields.push("career phase");
let advisoryMessage = "";
if (missingProfileFields.length > 0) {
advisoryMessage = `<br /><br /><em>If you provide ${
missingProfileFields.length > 1
? "a few more details"
: "this information"
}, Ill be able to offer more tailored and precise advice.</em>`;
}
return {
role: "assistant",
content: `
Hi! ${combinedMessage}<br /><br />
${goalsMessage ? goalsMessage + "<br /><br />" : ""}
${interestInventoryMessage}<br /><br />
${riskMessage}<br />
${advisoryMessage}<br />
I'm here to support you with personalized coaching. What would you like to focus on today?
`,
};
};
const handleSendMessage = async () => {
if (!input.trim() || loading) return;
const userMessage = { role: "user", content: input.trim() };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput("");
setLoading(true);
try {
const payload = {
userProfile,
financialProfile,
scenarioRow,
collegeProfile,
chatHistory: updatedMessages,
};
const res = await authFetch("/api/premium/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("AI request failed");
const { reply } = await res.json();
setMessages((prev) => [
...prev,
{ role: "assistant", content: reply },
]);
if (
reply.includes("created those milestones") ||
reply.includes("milestones for you")
) {
onMilestonesCreated?.();
}
} catch (err) {
console.error("CareerCoach error:", err);
setMessages((prev) => [
...prev,
{
role: "assistant",
content: "Sorry, something went wrong. Please try again.",
},
]);
} finally {
setLoading(false);
}
};
const handleSubmit = (e) => {
e.preventDefault();
handleSendMessage();
setHasSentMessage(true);
};
return (
<div className="border rounded-lg shadow bg-white p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
<div
ref={chatContainerRef}
className="overflow-y-auto border rounded mb-4 space-y-2"
style={{
maxHeight: "320px",
minHeight: "200px",
padding: "1rem",
scrollBehavior: "smooth",
}}
>
{messages.map((msg, i) => (
<div
key={i}
className={`rounded p-2 ${
msg.role === "user"
? "bg-blue-100 text-blue-800 self-end"
: "bg-gray-200 text-gray-800 self-start"
}`}
>
<div
dangerouslySetInnerHTML={{
__html: msg.content.replace(/\n/g, "<br />"),
}}
/>
</div>
))}
{loading && (
<div className="text-sm text-gray-500 italic">Coach is typing...</div>
)}
</div>
<form onSubmit={handleSubmit} className="flex space-x-2">
<input
type="text"
className="flex-grow border rounded py-2 px-3"
value={input}
placeholder="Ask your Career Coach..."
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<button
type="submit"
className={`rounded px-4 py-2 ${
loading
? "bg-gray-300 text-gray-600 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-600 text-white"
}`}
disabled={loading}
>
Send
</button>
</form>
</div>
);
}

View File

@ -18,9 +18,10 @@ import authFetch from '../utils/authFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import { getFullStateName } from '../utils/stateUtils.js';
import CareerCoach from "./CareerCoach.js";
import { Button } from './ui/button.js';
import ScenarioEditModal from './ScenarioEditModal.js';
import parseAIJson from "../utils/parseAIJson.js"; // your shared parser
import './CareerRoadmap.css';
import './MilestoneTimeline.css';
@ -233,43 +234,15 @@ function getYearsInCareer(startDateString) {
return Math.floor(diffYears).toString();
}
/**
* parseAiJson
* If ChatGPT returns a fenced code block like:
* ```json
* [ { ... }, ... ]
* ```
* we extract that JSON. Otherwise, we parse the raw string.
*/
function parseAiJson(rawText) {
const fencedRegex = /```json\s*([\s\S]*?)\s*```/i;
const match = rawText.match(fencedRegex);
if (match) {
const jsonStr = match[1].trim();
const arr = JSON.parse(jsonStr);
// Add an "id" for each milestone
arr.forEach((m) => {
m.id = crypto.randomUUID();
});
return arr;
} else {
// fallback if no fences
const arr = JSON.parse(rawText);
arr.forEach((m) => {
m.id = crypto.randomUUID();
});
return arr;
}
}
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const location = useLocation();
const apiURL = process.env.REACT_APP_API_URL;
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'MONTE_CARLO'
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06); // 6% default
const [randomRangeMin, setRandomRangeMin] = useState(-0.02); // -3% monthly
const [randomRangeMax, setRandomRangeMax] = useState(0.02); // 8% monthly
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06);
const [randomRangeMin, setRandomRangeMin] = useState(-0.02);
const [randomRangeMax, setRandomRangeMax] = useState(0.02);
// Basic states
const [userProfile, setUserProfile] = useState(null);
@ -818,7 +791,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
const data = await res.json();
const rawText = data.recommendations || '';
const arr = parseAiJson(rawText);
const arr = parseAIJson(rawText);
setRecommendations(arr);
localStorage.setItem('aiRecommendations', JSON.stringify(arr));
@ -838,53 +811,6 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
}
function handleToggle(recId) {
setSelectedIds((prev) => {
if (prev.includes(recId)) {
return prev.filter((x) => x !== recId);
} else {
return [...prev, recId];
}
});
}
async function handleCreateSelectedMilestones() {
if (!careerProfileId) return;
const confirm = window.confirm('Create the selected AI suggestions as milestones?');
if (!confirm) return;
const selectedRecs = recommendations.filter((r) => selectedIds.includes(r.id));
if (!selectedRecs.length) return;
// Use the AI-suggested date:
const payload = selectedRecs.map((rec) => ({
title: rec.title,
description: rec.description || '',
date: rec.date, // <-- use AI's date, not today's date
career_profile_id: careerProfileId
}));
try {
const r = await authFetch('/api/premium/milestone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ milestones: payload })
});
if (!r.ok) throw new Error('Failed to create new milestones');
// re-run projection to see them in the chart
await buildProjection();
// optionally clear
alert('Milestones created successfully!');
setSelectedIds([]);
} catch (err) {
console.error('Error creating milestones =>', err);
alert('Error saving new AI milestones.');
}
}
function handleSimulationYearsChange(e) {
setSimulationYearsInput(e.target.value);
}
@ -895,13 +821,26 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
const buttonLabel = recommendations.length > 0 ? 'New Suggestions' : 'What Should I Do Next?';
return (
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
<h2 className="text-2xl font-bold mb-4">Where Am I Now?</h2>
<div className="milestone-tracker max-w-screen-lg mx-auto px-4 py-6 space-y-4">
{/* 0) New CareerCoach at the top */}
<CareerCoach
userProfile={userProfile}
financialProfile={financialProfile}
scenarioRow={scenarioRow}
collegeProfile={collegeProfile}
onMilestonesCreated={() => {
/* refresh or reload logic here */
}}
/>
{/* 1) Then your "Where Am I Now?" */}
<h2 className="text-2xl font-bold mb-4">Where you are now and where you are going.</h2>
{/* 1) Career */}
<div className="bg-white p-4 rounded shadow mb-4 flex flex-col justify-center items-center min-h-[80px]">
<p>
<strong>Current Career:</strong>{' '}
<strong>Target Career:</strong>{' '}
{scenarioRow?.career_name || '(Select a career)'}
</p>
{yearsInCareer && (
@ -915,7 +854,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
{/* 2) Salary Benchmarks */}
<div className="flex flex-col md:flex-row gap-4">
{salaryData?.regional && (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2">
Regional Salary Data ({userArea || 'U.S.'})
</h4>
@ -948,7 +887,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
)}
{salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full md:w-1/2">
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2">National Salary Data</h4>
<p>
10th percentile:{' '}
@ -979,7 +918,7 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
</div>
{/* 3) Economic Projections */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex flex-col md:flex-row gap-4 h-auto overflow-none">
{economicProjections?.state && (
<EconomicProjectionsBar data={economicProjections.state} />
)}
@ -993,13 +932,13 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
</div>
)}
{/* 4) Career Goals */}
{/* 4) Career Goals
<div className="bg-white p-4 rounded shadow">
<h3 className="text-lg font-semibold mb-2">Your Career Goals</h3>
<p className="text-gray-700">
{scenarioRow?.career_goals || 'No career goals entered yet.'}
</p>
</div>
</div>*/}
{/* 5) Financial Projection */}
<div className="bg-white p-4 rounded shadow">
@ -1117,42 +1056,42 @@ if (aiLoading || clickCount >= DAILY_CLICK_LIMIT) {
</div>
)}
{/* 7) AI Next Steps */}
<div className="bg-white p-4 rounded shadow mt-4">
<Button onClick={handleAiClick} disabled={aiLoading || clickCount >= DAILY_CLICK_LIMIT}>
{buttonLabel}
</Button>
{/* 7) AI Next Steps */}
{/* <div className="bg-white p-4 rounded shadow mt-4">
<Button onClick={handleAiClick} disabled={aiLoading || clickCount >= DAILY_CLICK_LIMIT}>
{buttonLabel}
</Button>
{aiLoading && <p>Generating your next steps</p>}
{aiLoading && <p>Generating your next steps</p>}
{/* If we have structured recs, show checkboxes */}
{recommendations.length > 0 && (
<div className="mt-3">
<h3 className="font-semibold">Select the Advice You Want to Keep</h3>
<ul className="mt-2 space-y-2">
{recommendations.map((m) => (
<li key={m.id} className="flex items-start gap-2">
<input
type="checkbox"
checked={selectedIds.includes(m.id)}
onChange={() => handleToggle(m.id)}
/>
<div className="flex flex-col text-left">
<strong>{m.title}</strong>
<span>{m.date}</span>
<p className="text-sm">{m.description}</p>
</div>
</li>
))}
</ul>
{selectedIds.length > 0 && (
<Button className="mt-3" onClick={handleCreateSelectedMilestones}>
Create Milestones from Selected
</Button>
)}
</div>
)}
</div>
{/* If we have structured recs, show checkboxes
{recommendations.length > 0 && (
<div className="mt-3">
<h3 className="font-semibold">Select the Advice You Want to Keep</h3>
<ul className="mt-2 space-y-2">
{recommendations.map((m) => (
<li key={m.id} className="flex items-start gap-2">
<input
type="checkbox"
checked={selectedIds.includes(m.id)}
onChange={() => handleToggle(m.id)}
/>
<div className="flex flex-col text-left">
<strong>{m.title}</strong>
<span>{m.date}</span>
<p className="text-sm">{m.description}</p>
</div>
</li>
))}
</ul>
{selectedIds.length > 0 && (
<Button className="mt-3" onClick={handleCreateSelectedMilestones}>
Create Milestones from Selected
</Button>
)}
</div>
)}
</div>*/}
</div>
);
}

View File

@ -69,8 +69,11 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
<div className="space-y-2">
<label className="block font-medium">
Are you currently working or earning any income (even part-time)?
Are you currently earning any income even part-time or outside your intended career path?
</label>
<p className="text-sm text-gray-600">
(We ask this to understand your financial picture. This wont affect how we track your progress toward your target career.)
</p>
<select
value={currentlyWorking}
onChange={(e) => {
@ -87,7 +90,13 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData }) => {
{/* 2) Replace old local “Search for Career” with <CareerSearch/> */}
<div className="space-y-2">
<h3 className="font-medium">Search for Career</h3>
<h3 className="font-medium">
What career are you planning to pursue?
</h3>
<p className="text-sm text-gray-600">
This should be your <strong>target career path</strong> whether its a new goal or the one you're already in.
</p>
<CareerSearch onCareerSelected={handleCareerSelected} />
</div>

29
src/utils/parseAIJson.js Normal file
View File

@ -0,0 +1,29 @@
/**
* parseAiJson
* Attempts to extract a JSON array or object from a string that may include
* extra text or a fenced code block (```json ... ```).
*
* @param {string} rawText - The raw string from the AI response.
* @returns {any} - The parsed JSON (object or array).
* @throws Will throw an error if parsing fails.
*/
function parseAIJson(rawText) {
if (!rawText || typeof rawText !== 'string') {
throw new Error('No valid text provided for parseAiJson.');
}
// 1) Look for a fenced code block with "```json" ... "```"
const fencedRegex = /```json\s*([\s\S]*?)\s*```/i;
const match = rawText.match(fencedRegex);
if (match && match[1]) {
// parse the fenced code block
const jsonStr = match[1].trim();
return JSON.parse(jsonStr);
}
// 2) Fallback: try parsing the entire string directly
// Sometimes the AI might return just a raw JSON array without fences
return JSON.parse(rawText);
}
export default parseAIJson;