Added CareerCoach - adjusted CareerRoadmap.
This commit is contained in:
parent
5c4a280cf8
commit
86eda98bc5
@ -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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
|
||||
|
@ -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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
|
||||
|
@ -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 (1–3 years) milestones. Avoid any previously suggested milestones.
|
||||
Please provide exactly 2 short-term (within 6 months) and 1 long-term (1–3 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 don’t have a clear picture of where you stand currently with ${careerName}.`;
|
||||
break;
|
||||
}
|
||||
|
||||
// "Where you’d 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 = `You’d 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
|
||||
****************************************************/
|
||||
|
@ -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"
|
||||
|
275
src/components/CareerCoach.js
Normal file
275
src/components/CareerCoach.js
Normal 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 don’t have much info on your current involvement with ${careerName}.`;
|
||||
break;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// "Where you’d 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 = `You’d 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 you—there’s 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, I’ll 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"
|
||||
}, I’ll 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 won’t 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 it’s a new goal or the one you're already in.
|
||||
</p>
|
||||
|
||||
<CareerSearch onCareerSelected={handleCareerSelected} />
|
||||
</div>
|
||||
|
||||
|
29
src/utils/parseAIJson.js
Normal file
29
src/utils/parseAIJson.js
Normal 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;
|
Loading…
Reference in New Issue
Block a user