dev1/src/components/CareerCoach.js

464 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from "react";
import authFetch from "../utils/authFetch.js";
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
function buildInterviewPrompt(careerName, jobDescription = "") {
return `
You are an expert interviewer for the role **${careerName}**.
Ask one challenging behavioural or technical question **specific to this career**,
wait for the candidate's reply, then:
• Score the answer 15
• Give concise feedback (1-2 sentences)
• Ask the next question (up to 5 total)
After 5 questions or if the user types "quit interview", end the session.
Do NOT output milestones JSON.`;
}
/* ----------------------------------------------
Hidden prompts for the quick-action buttons
---------------------------------------------- */
function planPrompt( _opts={}) {
return `
# ⛔️ DO NOT wrap in markdown, do NOT add back-ticks, do NOT add commentary.
# ⛔️ Respond with ONE thing only: the VALID JSON object below.
# ⛔️ The very first character of your reply MUST be “{”.
{
"milestones":[
{
"title":"", /* GPT must fill — make unique & action-oriented */
"date":"YYYY-MM-DD", /* ≤ 6 mo, ≥ ${isoToday} */
"description":"1-2 sentences",
"impacts":[],
"tasks":[ … ]
},
{
"title":"", /* GPT must fill — phase-2 name */
"date":"YYYY-MM-DD", /* 9-18 mo out */
"description":"1-2 sentences",
"impacts":[],
"tasks":[ … ]
}
]
}
/* Rules — read carefully:
• You must provide DISTINCT, action-oriented titles.
• Titles may NOT duplicate any existing milestone (case-insensitive).
• Include at least one concrete noun (event, cert, org, etc.).
*/
`.trim();
}
const QUICK_PROMPTS = {
networking: ({ careerName, goalsText }) => `
MODE : networking_plan
ROLE : ${careerName}
GOALS: ${goalsText || "N/A"}
Create three milestones that expand professional connections.
${planPrompt()}
`.trim(),
jobSearch: ({ careerName, goalsText }) => `
MODE : job_search_plan
ROLE : ${careerName}
GOALS: ${goalsText || "N/A"}
Draft three milestones that accelerate the job hunt.
${planPrompt()}
`.trim(),
aiGrowth: ({ careerName, skillsText = "N/A", goalsText = "N/A" }) => `
MODE: ai_growth
TODAY = ${isoToday}
ROLE : ${careerName}
SKILLS : ${skillsText}
GOALS : ${goalsText}
INSTRUCTIONS
1. List 2-3 role tasks already touched by AI.
For each: how to *collaborate* with AI + one human-edge upskilling step.
2. Surface adjacent AI-created roles.
3. End with a 90-day action plan (bullets, dates ≥ TODAY).
${planPrompt({ label: "AI Growth" })}
`.trim(),
interview: `
MODE: interview
You are an expert interview coach.
Ask one behavioural or technical question, wait for the user's reply,
score 1-5, give concise feedback, then ask the next question.
Stop after 5 questions or if the user types "quit interview".
Do NOT output milestones JSON.`.trim()
};
export default function CareerCoach({
userProfile,
financialProfile,
scenarioRow,
setScenarioRow,
careerProfileId,
collegeProfile,
onMilestonesCreated,
onAiRiskFetched
}) {
/* -------------- state ---------------- */
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [aiRisk, setAiRisk] = useState(null);
const chatRef = useRef(null);
const [showGoals , setShowGoals ] = useState(false);
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
const [saving , setSaving ] = useState(false);
/* -------------- scroll --------------- */
useEffect(() => {
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [messages]);
// chat history persistence
useEffect(() => {
const saved = localStorage.getItem('coachChat:'+careerProfileId);
if (saved) setMessages(JSON.parse(saved));
}, [careerProfileId]);
useEffect(() => {
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
}, [messages, careerProfileId]);
/* -------------- intro ---------------- */
useEffect(() => {
if (!scenarioRow) return;
setMessages(prev =>
prev.length ? prev // keep what we loaded
: [generatePersonalizedIntro()] );
}, [scenarioRow?.id]);
/* ---------- helpers you already had ---------- */
function buildStatusSituationMessage(status, situation, careerName) {
/* (unchanged body) */
const sStatus = (status || "").toLowerCase();
const sSituation = (situation || "").toLowerCase();
let nowPart = "";
switch (sStatus) {
case "planned":
nowPart = `It appears youre looking ahead to a possible future as it pertains to ${careerName}.`;
break;
case "current":
nowPart = `It appears youre currently working in a role as it pertains to ${careerName}.`;
break;
case "exploring":
nowPart = `It appears youre exploring how ${careerName} might fit your plans.`;
break;
default:
nowPart = `I dont have a clear picture of your involvement with ${careerName}, but Im here to help.`;
}
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 for new opportunities.`;
break;
case "enhancing":
nextPart = `Youd like to deepen or broaden your responsibilities.`;
break;
case "retirement":
nextPart = `You're considering how to transition toward retirement.`;
break;
default:
nextPart = `I'm not entirely sure of your next direction, but well keep your background in mind.`;
}
const friendlyNote = `
Feel free to use AptivaAI however it best suits you—theres no "wrong" answer.
It's really about where you want to go from here (that's all you can control anyway).
We can refine details anytime or just jump straight to what you're most interested in exploring now!`;
return `${nowPart} ${nextPart}\n${friendlyNote}`;
}
function generatePersonalizedIntro() {
/* (unchanged body) */
const careerName = scenarioRow?.career_name || "this career";
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 combined = buildStatusSituationMessage(userStatus, userSituation, careerName);
const intro = `
Hi! ${combined}<br/>
${goalsText ? `Your goals include:<br />${goalsText.split(/^\d+\.\s+/gm).filter(Boolean).map(g => `${g.trim()}`).join("<br />")}<br/>` : ""}
${riskLevel ? `Note: This role has a <strong>${riskLevel}</strong> automation risk over the next 10 years. ${riskReasoning}<br/>` : ""}
I'm here to support you with personalized coaching. What would you like to focus on today?`;
return { role: "assistant", content: intro };
}
/* ------------ shared AI caller ------------- */
async function callAi(updatedHistory, opts = {}) {
setLoading(true);
try {
const payload = {
userProfile,
financialProfile,
scenarioRow,
collegeProfile,
chatHistory: updatedHistory.slice(-10),
...opts
};
const res = await authFetch("/api/premium/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json(); // might only have .error
const replyRaw = data?.reply ?? ""; // always a string
const riskData = data?.aiRisk;
const createdMilestones = data?.createdMilestones ?? [];
// guard empty or non-string → generic apology
const safeReply = typeof replyRaw === "string" && replyRaw.trim()
? replyRaw
: "Sorry, something went wrong on the server.";
// If GPT accidentally returned raw JSON, hide it from user
const isJson = safeReply.trim().startsWith("{") || safeReply.trim().startsWith("[");
const friendlyReply = isJson
? "✅ Got it! I added new milestones to your plan. Check your Milestones tab."
: safeReply;
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
onMilestonesCreated(); // no arg needed just refetch
}
} catch (err) {
console.error(err);
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
} finally {
setLoading(false);
}
}
/* ------------ normal send ------------- */
function handleSubmit(e) {
e.preventDefault();
if (!input.trim() || loading) return;
const userMsg = { role: "user", content: input.trim() };
const newHistory = [...messages, userMsg];
setMessages(newHistory);
setInput("");
callAi(newHistory);
}
/* ------------ quick-action buttons ------------- */
function triggerQuickAction(type) {
if (loading) return; // debounce
/* shared context */
const careerName = scenarioRow?.career_name || "your role";
const goalsText = scenarioRow?.career_goals || "";
const skillsText = userProfile?.key_skills || "";
const avoidList =
[...messages].reverse()
.find(m => m.role === "system" && m.content.startsWith("[CURRENT MILESTONES]"))
?.content.split("\n").slice(2).join("\n") || "";
/* 1) Mock-Interview (special flow) */
if (type === "interview") {
const desc = scenarioRow?.job_description || "";
const hiddenSystem = { role:"system", content: buildInterviewPrompt(careerName, desc) };
const note = { role:"assistant", content:`Starting mock interview on **${careerName}**. Answer each question and I'll give feedback!` };
const updated = [...messages, note, hiddenSystem];
setMessages([...messages, note]);
callAi(updated);
return;
}
/* 2) All other quick actions share the same pattern */
const note = {
role : "assistant",
content: {
networking: "Sure! Let me create a Networking roadmap for you…",
jobSearch : "Sure! Let me create a Job-Search roadmap for you…",
aiGrowth : "Sure! Lets map out how you can *partner* with AI in this career…"
}[type] || "OK!"
};
const hiddenSystem = {
role : "system",
content: QUICK_PROMPTS[type]({
careerName, goalsText, skillsText, avoidList
})
};
const updated = [...messages, note, hiddenSystem];
setMessages([...messages, note]);
const needsContext = ["networking", "jobSearch", "aiGrowth"].includes(type);
callAi(updated, {forceContext: needsContext});
}
/* ------------ render ------------- */
return (
<div className="border rounded-lg shadow bg-white p-6 mb-6">
<h2 className="text-2xl font-semibold mb-4">Career Coach</h2>
{/* Quick-action bar */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<button
onClick={() => triggerQuickAction("networking")}
disabled={loading}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-3 py-1 rounded"
>
Networking Plan
</button>
<button
onClick={() => triggerQuickAction("jobSearch")}
disabled={loading}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1 rounded"
>
Job-Search Plan
</button>
<button
onClick={() => triggerQuickAction("interview")}
disabled={loading}
className="bg-orange-600 hover:bg-orange-700 text-white px-3 py-1 rounded"
>
Interview Help
</button>
<button
onClick={() => triggerQuickAction("aiGrowth")}
disabled={loading}
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
>
Grow Career with AI
</button>
{/* pushes Edit Goals to the far right */}
<div className="flex-grow"></div>
<button
onClick={() => {
// always load the latest goals before showing the modal
setDraftGoals(scenarioRow?.career_goals || "");
setShowGoals(true);
}}
className="border border-gray-300 bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-300"
>
Edit&nbsp;Goals
</button>
</div>
{/* Chat area */}
<div
ref={chatRef}
className="overflow-y-auto border rounded mb-4 space-y-2"
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
>
{messages.map((m, i) => (
<div
key={i}
className={`rounded p-2 ${
m.role === "user"
? "bg-blue-100 text-blue-800 self-end"
: "bg-gray-200 text-gray-800 self-start"
}`}
dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "<br/>") }}
/>
))}
{loading && <div className="text-sm italic text-gray-500">Coach is typing</div>}
</div>
{/* AI risk banner */}
{aiRisk?.riskLevel && (
<div className="p-2 my-2 bg-yellow-100 text-yellow-900 rounded">
<strong>Automation Risk:</strong> {aiRisk.riskLevel}
<br />
<em>{aiRisk.reasoning}</em>
</div>
)}
{/* Input */}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
placeholder="Ask your Career Coach…"
className="flex-grow border rounded py-2 px-3"
/>
<button
type="submit"
disabled={loading}
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"
}`}
>
Send
</button>
</form>
{showGoals && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow">
<h3 className="text-xl font-semibold mb-3">Edit Your Career Goals</h3>
<textarea
value={draftGoals}
onChange={(e) => setDraftGoals(e.target.value)}
rows={5}
className="w-full border rounded p-2"
placeholder="Describe your short- and long-term goals…"
/>
<div className="mt-4 flex justify-end gap-2">
<button
className="px-4 py-2 rounded border"
onClick={() => setShowGoals(false)}
>
Cancel
</button>
<button
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
disabled={saving}
onClick={async () => {
setSaving(true);
await authFetch(
`/api/premium/career-profile/${careerProfileId}/goals`,
{
method : 'PUT',
headers: { 'Content-Type':'application/json' },
body : JSON.stringify({ career_goals: draftGoals })
}
);
// lift new goals into parent state so Jess sees them
setScenarioRow((p) => ({ ...p, career_goals: draftGoals }));
setSaving(false);
setShowGoals(false);
}}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
);
}