Coach can now create milestones and systemic prompts for Networking, Job Search, and INterview mode buttons.

This commit is contained in:
Josh 2025-06-11 11:46:07 +00:00
parent bdec7c2d9a
commit 9b697e1d11
2 changed files with 309 additions and 203 deletions

View File

@ -8,7 +8,7 @@ import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs/promises';
import multer from 'multer';
import authFetch from '../src/utils/authFetch.js'; // Adjust path as needed
import fetch from "node-fetch";
import mammoth from 'mammoth';
import { fileURLToPath } from 'url';
import jwt from 'jsonwebtoken';
@ -27,10 +27,23 @@ const env = process.env.NODE_ENV?.trim() || 'development';
const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath }); // Load .env file
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
const app = express();
const PORT = process.env.PREMIUM_PORT || 5002;
const { getDocument } = pkg;
function internalFetch(req, url, opts = {}) {
return fetch(url, {
...opts,
headers: {
"Content-Type": "application/json",
Authorization: req.headers?.authorization || "", // tolerate undefined
...(opts.headers || {})
}
});
}
// 1) Create a MySQL pool using your environment variables
const pool = mysql.createPool({
@ -493,6 +506,18 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
chatHistory = []
} = req.body;
let existingTitles = [];
try {
const [rows] = await pool.query(
`SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d
FROM milestones
WHERE user_id = ? AND career_profile_id = ?`,
[req.id, scenarioRow.id]
);
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
} catch (e) {
console.error("Could not fetch existing milestones =>", e);
}
// ------------------------------------------------
// 1. Helper Functions
// ------------------------------------------------
@ -833,11 +858,52 @@ ${combinedStatusSituation}
${summaryText}
`.trim();
const systemPromptMilestoneFormat = `
WHEN the user wants a plan with milestones, tasks, and financial impacts:
RESPOND ONLY with valid JSON in this shape:
{
"milestones": [
{
"title": "string",
"date": "YYYY-MM-DD",
"description": "1 or 2 sentences",
"impacts": [
{
"impact_type": "cost" or "salary" or ...,
"direction": "add" or "subtract",
"amount": 100.00,
"start_date": "YYYY-MM-DD" (optional),
"end_date": "YYYY-MM-DD" (optional)
}
],
"tasks": [
{
"title": "string",
"description": "string",
"due_date": "YYYY-MM-DD"
}
]
},
...
]
}
NO extra text or disclaimers if returning a plan. Only that JSON.
Otherwise, answer normally.
`.trim();
const avoidBlock = existingTitles.length
? "\nAVOID repeating any of these title|date combinations:\n" +
existingTitles.map(t => `- ${t}`).join("\n")
: "";
// Build up the final messages array
const messagesToSend = [
{ role: "system", content: systemPromptIntro },
{ role: "system", content: systemPromptStatusSituation },
{ role: "system", content: systemPromptDetailedContext },
{ role: "system", content: systemPromptMilestoneFormat },
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged
...chatHistory // includes user and assistant messages so far
];
@ -872,6 +938,11 @@ ${summaryText}
// The AI plan is expected to have: planObj.milestones[]
if (planObj && Array.isArray(planObj.milestones)) {
for (const milestone of planObj.milestones) {
const dupKey = `${(milestone.title || "").trim()}|${milestone.date}`;
if (existingTitles.includes(dupKey)) {
console.log("Skipping duplicate milestone:", dupKey);
continue; // do NOT insert
}
// Create the milestone
const milestoneBody = {
title: milestone.title,
@ -884,65 +955,90 @@ ${summaryText}
};
// Call your existing milestone endpoint
const msRes = await authFetch("/api/premium/milestone", {
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(milestoneBody)
});
const createdMs = await msRes.json();
// Figure out the new milestone ID
let newMilestoneId = null;
if (Array.isArray(createdMs) && createdMs[0]) {
newMilestoneId = createdMs[0].id;
} else if (createdMs.id) {
newMilestoneId = createdMs.id;
}
// If we have a milestoneId, create tasks & impacts
if (newMilestoneId) {
// tasks
if (Array.isArray(milestone.tasks)) {
for (const t of milestone.tasks) {
const taskBody = {
milestone_id: newMilestoneId,
title: t.title,
description: t.description || "",
due_date: t.due_date || null
};
await authFetch("/api/premium/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskBody)
});
}
// Figure out the new milestone ID
let newMilestoneId = null;
if (Array.isArray(createdMs) && createdMs[0]) {
newMilestoneId = createdMs[0].id;
} else if (createdMs.id) {
newMilestoneId = createdMs.id;
}
// impacts
if (Array.isArray(milestone.impacts)) {
for (const imp of milestone.impacts) {
const impactBody = {
milestone_id: newMilestoneId,
impact_type: imp.impact_type,
direction: imp.direction,
amount: imp.amount,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
await authFetch("/api/premium/milestone-impacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impactBody)
});
// If we have a milestoneId, create tasks & impacts
if (newMilestoneId) {
/* ---------- TASKS ---------- */
if (Array.isArray(milestone.tasks)) {
for (const t of milestone.tasks) {
// tolerate plain-string tasks → convert to minimal object
const taskObj =
typeof t === "string"
? { title: t, description: "", due_date: null }
: t;
if (!taskObj.title) continue; // skip invalid
const taskBody = {
milestone_id: newMilestoneId,
title: taskObj.title,
description: taskObj.description || "",
due_date: taskObj.due_date || null
};
await internalFetch(req, `${apiBase}/premium/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskBody)
});
}
}
/* ---------- IMPACTS ---------- */
if (Array.isArray(milestone.impacts)) {
for (const imp of milestone.impacts) {
// tolerate plain-string impacts
const impObj =
typeof imp === "string"
? {
impact_type: "note",
direction: "add",
amount: 0,
start_date: null,
end_date: null
}
: imp;
if (!impObj.impact_type) continue; // skip invalid
const impactBody = {
milestone_id: newMilestoneId,
impact_type: impObj.impact_type,
direction: impObj.direction,
amount: impObj.amount,
start_date: impObj.start_date || null,
end_date: impObj.end_date || null
};
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impactBody)
});
}
}
/* ---------- Track the new milestone ---------- */
createdMilestonesData.push({
milestoneId: newMilestoneId,
title: milestone.title
});
}
// Keep track of the newly created milestone
createdMilestonesData.push({
milestoneId: newMilestoneId,
title: milestone.title
});
}
}
// If we successfully created at least 1 milestone,

View File

@ -1,6 +1,59 @@
import React, { useState, useEffect, useRef } from "react";
import authFetch from "../utils/authFetch.js";
const isoToday = new Date().toISOString().slice(0,10); // top-level helper
/* ----------------------------------------------
Hidden prompts for the quick-action buttons
---------------------------------------------- */
const QUICK_PROMPTS = {
networking: `
Return **ONLY** valid JSON:
TODAY = ${isoToday}
**Every milestone.date must be >= TODAY**
{
"milestones": [
{
"title": "<= 5 words",
"date": "YYYY-MM-DD",
"description": "12 sentences",
"impacts": [
{
"impact_type": "cost" | "salary" | "none",
"direction": "add" | "subtract",
"amount": 0,
"start_date": null,
"end_date": null
}
],
"tasks": [
{
"title": "string",
"description": "string",
"due_date": "YYYY-MM-DD"
}
]
}
]
}
NO extra commentary. The JSON will be stored as milestones.`.trim(),
jobSearch: `
Return **ONLY** valid JSON in the **same structure** (title, date, description, impacts[{}], tasks[{}]) for a Job-Search roadmap. TODAY = ${isoToday}
**Every milestone.date must be >= TODAY** NO extra text.`.trim(),
interview: `
You are an expert interview coach.
Ask one behavioural or technical question, wait for the user's reply,
score 1-5, give constructive 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,
@ -9,39 +62,30 @@ export default function CareerCoach({
onMilestonesCreated,
onAiRiskFetched
}) {
/* -------------- state ---------------- */
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const chatContainerRef = useRef(null);
const [hasSentMessage, setHasSentMessage] = useState(false);
const [prevRiskLevel, setPrevRiskLevel] = useState(null);
// NEW: optional local state to hold the AI risk data if you want to show it somewhere
const [aiRisk, setAiRisk] = useState(null);
const chatRef = useRef(null);
/* -------------- scroll --------------- */
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [messages, hasSentMessage]);
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [messages]);
/* -------------- intro ---------------- */
useEffect(() => {
if (!scenarioRow?.riskLevel) return;
// If it hasn't changed, skip
if (scenarioRow.riskLevel === prevRiskLevel) return;
setPrevRiskLevel(scenarioRow.riskLevel);
// Now generate the intro once
const introMessage = generatePersonalizedIntro();
setMessages([introMessage]);
}, [scenarioRow]);
if (!scenarioRow) return;
setMessages([generatePersonalizedIntro()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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":
@ -55,9 +99,7 @@ export default function CareerCoach({
break;
default:
nowPart = `I dont have a clear picture of your involvement with ${careerName}, but Im here to help.`;
break;
}
let nextPart = "";
switch (sSituation) {
case "planning":
@ -74,184 +116,152 @@ export default function CareerCoach({
break;
default:
nextPart = `I'm not entirely sure of your next direction, but well keep your background in mind.`;
break;
}
const combinedDescription = `${nowPart} ${nextPart}`.trim();
const friendlyNote = `
Feel free to use AptivaAI however it best suits youtheres no "wrong" answer.
AptivaAI asks for some of your current situation so we can provide the best guidance on what you should do next to reach your goals.
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!
`.trim();
return `${combinedDescription}\n${friendlyNote}`;
We can refine details anytime or just jump straight to what you're most interested in exploring now!`;
return `${nowPart} ${nextPart}\n${friendlyNote}`;
}
const generatePersonalizedIntro = () => {
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 userStatus = scenarioRow?.status?.toLowerCase();
const combined = buildStatusSituationMessage(userStatus, userSituation, careerName);
const combinedMessage = 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 };
}
const hasInterestAnswers = Boolean(userProfile?.interest_inventory_answers?.trim());
const interestInventoryMessage = hasInterestAnswers
? `Since you've completed the Interest Inventory, I can offer more targeted suggestions based on your responses.`
: `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+/gm)
.filter(Boolean)
.map((goal) => `${goal.trim()}`)
.join("<br />")}`
: null;
const missingProfileFields = [];
if (!scenarioRow?.career_name) missingProfileFields.push("career choice");
if (!goalsText) missingProfileFields.push("career goals");
if (!userSituation) missingProfileFields.push("career phase");
let advisoryMessage = "";
if (missingProfileFields.length > 0) {
advisoryMessage = `<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/>
${goalsMessage ? goalsMessage + "<br/>" : ""}
${interestInventoryMessage}<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("");
/* ------------ shared AI caller ------------- */
async function callAi(updatedHistory) {
setLoading(true);
try {
const payload = {
userProfile,
financialProfile,
scenarioRow,
collegeProfile,
chatHistory: updatedMessages,
chatHistory: updatedHistory
};
const res = await authFetch("/api/premium/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
body: JSON.stringify(payload)
});
const { reply, aiRisk: riskData, createdMilestones = [] } = await res.json();
if (!res.ok) throw new Error("AI request failed");
// If GPT accidentally returned raw JSON, hide it from user
const isJson = reply.trim().startsWith("{") || reply.trim().startsWith("[");
const friendlyReply = isJson
? "✅ Got it! I added new milestones to your plan. Check your Milestones tab."
: reply;
// Here we destructure out aiRisk from the JSON
// so we can store it or display it in the frontend
const { reply, aiRisk: riskDataFromServer } = await res.json();
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
setMessages((prev) => [...prev, { role: "assistant", content: reply }]);
// OPTIONAL: store or use the AI risk data
if (riskDataFromServer && onAiRiskFetched) {
onAiRiskFetched(riskDataFromServer);
}
if (
reply.includes("created those milestones") ||
reply.includes("milestones for you")
) {
onMilestonesCreated?.();
}
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
if (createdMilestones.length && onMilestonesCreated)
onMilestonesCreated(createdMilestones.length);
} catch (err) {
console.error("CareerCoach error:", err);
setMessages((prev) => [
...prev,
{
role: "assistant",
content: "Sorry, something went wrong. Please try again.",
},
]);
console.error(err);
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
} finally {
setLoading(false);
}
};
}
const handleSubmit = (e) => {
/* ------------ normal send ------------- */
function handleSubmit(e) {
e.preventDefault();
handleSendMessage();
setHasSentMessage(true);
};
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;
// 1. Add a visible note for user *without* showing the raw system prompt
const note = {
role: "assistant",
content:
type === "interview"
? "Starting mock interview! (answer each question and Ill give feedback)"
: `Sure! Let me create a ${type === "networking" ? "Networking" : "Job-Search"} roadmap for you…`
};
const hiddenSystem = { role: "system", content: QUICK_PROMPTS[type] };
const updatedHistory = [...messages, note, hiddenSystem];
setMessages([...messages, note]); // show only the friendly note
callAi(updatedHistory);
}
/* ------------ 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 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>
</div>
{/* Chat area */}
<div
ref={chatContainerRef}
ref={chatRef}
className="overflow-y-auto border rounded mb-4 space-y-2"
style={{
maxHeight: "320px",
minHeight: "200px",
padding: "1rem",
scrollBehavior: "smooth",
}}
style={{ maxHeight: 320, minHeight: 200, padding: "1rem" }}
>
{messages.map((msg, i) => (
{messages.map((m, i) => (
<div
key={i}
className={`rounded p-2 ${
msg.role === "user"
m.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>
dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "<br/>") }}
/>
))}
{loading && (
<div className="text-sm text-gray-500 italic">Coach is typing...</div>
)}
{loading && <div className="text-sm italic text-gray-500">Coach is typing</div>}
</div>
{/* Optionally display AI risk info here if you'd like */}
{aiRisk && aiRisk.riskLevel && (
{/* 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 />
@ -259,23 +269,23 @@ const interestInventoryMessage = hasInterestAnswers
</div>
)}
<form onSubmit={handleSubmit} className="flex space-x-2">
{/* Input */}
<form onSubmit={handleSubmit} className="flex gap-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}
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"
}`}
disabled={loading}
>
Send
</button>