diff --git a/backend/server3.js b/backend/server3.js index ca98d67..a7d6164 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -21,13 +21,14 @@ import './jobs/reminderCron.js'; import OpenAI from 'openai'; import Fuse from 'fuse.js'; import { createReminder } from './utils/smsService.js'; - +import { cacheSummary } from "./utils/ctxCache.js"; const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api"; const app = express(); const PORT = process.env.PREMIUM_PORT || 5002; const { getDocument } = pkg; +const bt = "`".repeat(3); function internalFetch(req, url, opts = {}) { return fetch(url, { @@ -68,6 +69,71 @@ const authenticatePremiumUser = (req, res, next) => { const pool = db; +/* ======================================================================== + * applyOps – executes the “milestones” array inside a fenced ```ops block + * and returns an array of confirmation strings + * ===================================================================== */ +async function applyOps(opsObj, req) { + if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return []; + + const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api"; + const confirmations = []; + + // helper for authenticated fetches that keep headers + const auth = (path, opts = {}) => internalFetch(req, `${apiBase}${path}`, opts); + + for (const m of opsObj.milestones) { + const { op } = m || {}; + + try { + /* ---------- DELETE ---------- */ + const id = (m.id || '').trim(); + if (op === "DELETE" && m.id) { + const cleanId = m.id.trim(); + const res = await auth(`/premium/milestones/${cleanId}`, { method:"DELETE" }); + } + + /* ---------- UPDATE ---------- */ + if (op === "UPDATE" && m.id && m.patch) { + const res = await auth(`/premium/milestones/${m.id}`, { + method : "PUT", + headers: { "Content-Type": "application/json" }, + body : JSON.stringify(m.patch) + }); + if (res.ok) confirmations.push(`Updated milestone ${m.id}`); + else console.warn("[applyOps] UPDATE failed", m.id, res.status); + } + + /* ---------- CREATE ---------- */ + if (op === "CREATE" && m.data) { + const res = await auth("/premium/milestone", { + method : "POST", + headers: { "Content-Type": "application/json" }, + body : JSON.stringify(m.data) + }); + if (res.ok) { + const json = await res.json(); + const newId = Array.isArray(json) ? json[0]?.id : json.id; + confirmations.push(`Created milestone ${newId || "(new)"}`); + } else console.warn("[applyOps] CREATE failed", res.status); + } + } catch (err) { + console.error("[applyOps] Error handling op", m, err); + } + } + + /* After any mutations, reload the milestone list so the UI refreshes */ + if (confirmations.length) { + try { + await fetchMilestones(); // your existing helper + } catch (e) { + console.warn("Could not refresh milestones after ops", e); + } + } + + return confirmations; +} + /* ------------------------------------------------------------------ CAREER PROFILE ENDPOINTS ------------------------------------------------------------------ */ @@ -229,6 +295,12 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res planned_additional_income ?? null ]); + await pool.query( + `DELETE FROM context_cache + WHERE user_id = ? AND career_profile_id = ?`, + [req.id, finalId] + ); + // re-fetch to confirm ID const [rows] = await pool.query( `SELECT id @@ -258,6 +330,11 @@ app.put('/api/premium/career-profile/:id/goals', authenticatePremiumUser, async return res.status(403).json({ error: 'Not your profile.' }); } await pool.query('UPDATE career_profiles SET career_goals=? WHERE id=?', [career_goals, id]); + await pool.query( + "DELETE FROM context_cache WHERE user_id=? AND career_profile_id=?", + [req.id, id] +); + res.json({ career_goals }); }); @@ -346,12 +423,17 @@ app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) // 2) Build a summary for ChatGPT // (We'll ignore scenarioRow.start_date in the prompt) - const summaryText = buildUserSummary({ - userProfile, - scenarioRow, - financialProfile, - collegeProfile - }); + // 4. Get / build the cached big-context card (one DB hit, or none on cache-hit) + // build the big summary with your local helper +let summaryText = buildUserSummary({ + userProfile, + scenarioRow, + financialProfile, + collegeProfile, + aiRisk +}); + +summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText); let avoidSection = ''; if (previouslyUsedTitles.length > 0) { @@ -504,7 +586,8 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { scenarioRow = {}, financialProfile = {}, collegeProfile = {}, - chatHistory = [] + chatHistory = [], + forceContext = false } = req.body; let existingTitles = []; @@ -522,7 +605,9 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`); if (rows.length) { - miniGrid = rows.map(r => `${r.id}|${r.title.trim()}|${r.d}`).join("\n"); + miniGrid = rows + .map(r => `${r.id}|${r.title.trim()}|${r.d}`) + .join("\n"); } } catch (e) { console.error("Could not fetch existing milestones ⇒", e); @@ -781,16 +866,19 @@ ${econText} 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 || [] - }) - }); + const aiRiskRes = await internalFetch( + req, + `${apiBase}/premium/ai-risk-analysis`, + { + method: "POST", + 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 { @@ -815,43 +903,45 @@ ${econText} careerName ); - // ------------------------------------------------ - // 4. Build Additional Context Summary - // ------------------------------------------------ - const summaryText = buildUserSummary({ - userProfile, - scenarioRow, - financialProfile, - collegeProfile, - aiRisk - }); + // 4. Build / fetch the cached summary (auto-rebuild if missing) +let summaryText = buildUserSummary({ + userId : req.id, + scenarioRow, + userProfile, + financialProfile, + collegeProfile, + aiRisk +}); + +summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText); // ------------------------------------------------ // 5. Construct System-Level Prompts // ------------------------------------------------ + const systemPromptIntro = ` -+You are **Jess**, a professional career coach inside AptivaAI. -+Your mandate: turn the user’s real data into clear, empathetic, *actionable* guidance. -+ -+──────────────────────────────────────────────────────── -+What Jess can do directly in Aptiva -+──────────────────────────────────────────────────────── -+• **Create** new milestones (with tasks & financial impacts) -+• **Update** any field on an existing milestone -+• **Delete** milestones that are no longer relevant -+• **Add / edit / remove** tasks inside a milestone -+• Run salary benchmarks, AI-risk checks, and financial projections -+ -+──────────────────────────────────────────────────────── -+Mission & Tone -+──────────────────────────────────────────────────────── -+Our mission is to help people grow *with* AI rather than be displaced by it. -+Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation. -+Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator. -+ +You are **Jess**, a professional career coach inside AptivaAI. +Your mandate: turn the user’s real data into clear, empathetic, *actionable* guidance. + +──────────────────────────────────────────────────────── +What Jess can do directly in Aptiva +──────────────────────────────────────────────────────── +• **Create** new milestones (with tasks & financial impacts) +• **Update** any field on an existing milestone +• **Delete** milestones that are no longer relevant +• **Add / edit / remove** tasks inside a milestone +• Run salary benchmarks, AI-risk checks, and financial projections + +──────────────────────────────────────────────────────── +Mission & Tone +──────────────────────────────────────────────────────── +Our mission is to help people grow *with* AI rather than be displaced by it. +Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation. +Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator. + +Finish every reply with **one concrete suggestion or question** that moves the plan forward. -+Never ask for info you already have unless you truly need clarification. -+`.trim(); +Never ask for info you already have unless you truly need clarification. +`.trim(); const systemPromptOpsCheatSheet = ` ──────────────────────────────────────────────────────── @@ -863,7 +953,13 @@ const systemPromptOpsCheatSheet = ` • You already have permission—no need to ask the user. 4. CREATE / UPDATE / DELETE tasks inside a milestone ──────────────────────────────────────────────────────── -When you perform an op, respond with a fenced JSON block +WHEN you perform an op: +- Write ONE short confirmation line for the user + (e.g. “✅ Deleted the July 2025 milestone.”). +- THEN add the fenced ${bt}ops${bt} JSON block on a new line. +- Put **no other text after** the block. + +If you are **not** performing an op, skip the block entirely. _tagged_ \`\`\`ops\`\`\` exactly like this: \`\`\`ops @@ -893,9 +989,14 @@ _tagged_ \`\`\`ops\`\`\` exactly like this: } \`\`\` -⚠️ If you’re not changing milestones, skip the ops block entirely. All milestone titles are already 3–5 words; use them verbatim when the user refers to a milestone by name. +⚠️ When you DELETE or UPDATE, the "id" **must be the UUID from column 1 of the grid**—never the title. + +⚠️ Whenever you *say* you changed a milestone, include the ops block. +⚠️ Omitting the block means **no changes will be executed**. `.trim(); + + const systemPromptStatusSituation = ` [CURRENT AND NEXT STEP OVERVIEW] ${combinedStatusSituation} @@ -908,7 +1009,10 @@ ${summaryText} const dynMilestonePrompt = ` [CURRENT MILESTONES] -(id | date) +Use **exactly** the UUID at the start of each line when you refer to a milestone +(you can DELETE, UPDATE, or COPY any of them). + +(id | title | date ) ${miniGrid} You may UPDATE or DELETE any of these. `.trim(); @@ -943,7 +1047,20 @@ RESPOND ONLY with valid JSON in this shape: ... ] } - NO extra text or disclaimers if returning a plan. Only that JSON. + * ── QUALITY RULES (hard) ──────────────────────────────── +• Do **NOT** create a milestone if its title already exists (case-insensitive). + +✓ Every milestone must cite at least ONE concrete datum taken + verbatim from the context blocks above AND +✓ must include a clearly-named real-world noun + (company, organisation, conference, certificate, platform, city…). + +BAD » “Attend a networking event” +GOOD » “Attend IEEE Atlanta Nanotechnology Meetup” + +If you can’t meet the rule, ASK the user a clarifying question instead +of returning an invalid milestone. +NO extra text or disclaimers if returning a plan—only that JSON. Otherwise, answer normally. `.trim(); @@ -960,10 +1077,12 @@ Reject or re-ask if the user insists on a past date. `.trim(); const avoidBlock = existingTitles.length - ? "\nAVOID repeating any of these title|date combinations:\n" + - existingTitles.map(t => `- ${t}`).join("\n") + ? "\nAVOID any milestone whose title matches REGEXP /" + + existingTitles.map(t => `(?:${t.split("|")[0].replace(/[.*+?^${}()|[\]\\]/g,"\\$&")})`) + .join("|") + "/i" : ""; + const recentHistory = chatHistory.slice(-MAX_CHAT_TURNS); const firstTurn = chatHistory.length === 0; @@ -976,49 +1095,106 @@ ${systemPromptOpsCheatSheet} /* Milestone JSON spec, date guard, and avoid-list */ ${systemPromptMilestoneFormat} ${systemPromptDateGuard} +${avoidBlock} `.trim(); +const NEEDS_OPS_CARD = !chatHistory.some( + m => m.role === "system" && m.content.includes("APTIVA OPS CHEAT-SHEET") +); + +const NEEDS_CTX_CARD = !chatHistory.some( + m => m.role === "system" && m.content.startsWith("[DETAILED USER PROFILE]") +); + +const SEND_CTX_CARD = forceContext || NEEDS_CTX_CARD; - // Build up the final messages array - const messagesToSend = [ - { role: "system", content: systemPromptIntro }, - { role: "system", content: systemPromptOpsCheatSheet }, - { role: "system", content: systemPromptStatusSituation }, - { role: "system", content: systemPromptDetailedContext }, - { role: "system", content: systemPromptMilestoneFormat }, - { role: "system", content: systemPromptMilestoneFormat + avoidBlock }, - { role: "system", content: systemPromptDateGuard }, - ...chatHistory // includes user and assistant messages so far - ]; +const messagesToSend = []; + +// ① Large, unchanging card – once per conversation +if (NEEDS_OPS_CARD) { + messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD }); +} + +if (NEEDS_CTX_CARD || SEND_CTX_CARD) ++ messagesToSend.push({ role:"system", content: summaryText }); + +// ② Per-turn contextual helpers (small!) +messagesToSend.push( + { role: "system", content: systemPromptStatusSituation }, + { role: "system", content: dynMilestonePrompt } // <-- grid replaces two old lines +); + +// ③ Recent conversational context +messagesToSend.push(...chatHistory.slice(-MAX_CHAT_TURNS)); // ------------------------------------------------ // 6. Call GPT (unchanged) // ------------------------------------------------ const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const completion = await openai.chat.completions.create({ - model: "gpt-4", + model: "gpt-3.5-turbo-0125", messages: messagesToSend, - temperature: 0.7, + temperature: 0.3, max_tokens: 1000 }); // 4) Grab the response text const rawReply = completion?.choices?.[0]?.message?.content?.trim() || ""; + console.log("[GPT raw]", rawReply); // ← TEMP-LOG if (!rawReply) { return res.json({ reply: "Sorry, I didn't get a response. Could you please try again?" }); } + /* 🔹 NEW: detect fenced ```ops``` JSON */ +let opsConfirmations = []; +const opsMatch = rawReply.match(/```ops\s*([\s\S]*?)```/i); +if (opsMatch) { + try { + const opsObj = JSON.parse(opsMatch[1]); + opsConfirmations = await applyOps(opsObj, req); + } catch (e) { + console.error("Could not parse ops JSON:", e); + } +} + +/* 🔹 Strip the ops block from what the user sees */ +let visibleReply = rawReply.replace(/```ops[\s\S]*?```/i, "").trim(); +if (!visibleReply) visibleReply = "Done!"; + +/* If we executed any ops, append a quick summary */ +if (opsConfirmations.length) { + visibleReply += + "\n\n" + + opsConfirmations.map(t => "• " + t).join("\n"); + await pool.query( + "DELETE FROM context_cache WHERE user_id=? AND career_profile_id=?", + [req.id, scenarioRow.id] +); + +} + // 5) Default: Just return raw text to front-end - let replyToClient = rawReply; + let replyToClient = visibleReply; let createdMilestonesData = []; - // If the AI sent JSON (plan with milestones), parse & create in DB - if (rawReply.startsWith("{") || rawReply.startsWith("[")) { - try { - const planObj = JSON.parse(rawReply); + // ── NEW: pull out the first JSON object/array even if text precedes it ── +const firstBrace = rawReply.search(/[{\[]/); // first “{” or “[” +const lastBrace = rawReply.lastIndexOf("}"); +const lastBracket = rawReply.lastIndexOf("]"); +const lastJsonEdge = Math.max(lastBrace, lastBracket); + +let embeddedJson = null; +if (firstBrace !== -1 && lastJsonEdge > firstBrace) { + embeddedJson = rawReply.slice(firstBrace, lastJsonEdge + 1).trim(); +} + +// … then change the existing check: +if (embeddedJson) { // <── instead of startsWith("{")… + try { + const planObj = JSON.parse(embeddedJson); // The AI plan is expected to have: planObj.milestones[] if (planObj && Array.isArray(planObj.milestones)) { @@ -2059,8 +2235,12 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req, req.id ]); } - + await pool.query( + "DELETE FROM context_cache WHERE user_id=? AND career_profile_id IS NULL", + [req.id] + ); res.json({ message: 'Financial profile saved/updated.' }); + } catch (error) { console.error('Error saving financial profile:', error); res.status(500).json({ error: 'Failed to save financial profile.' }); diff --git a/backend/utils/ctxCache.js b/backend/utils/ctxCache.js new file mode 100644 index 0000000..9c01b5c --- /dev/null +++ b/backend/utils/ctxCache.js @@ -0,0 +1,30 @@ +// utils/ctxCache.js +import crypto from "node:crypto"; +import pool from "../config/mysqlPool.js"; + +/** + * @param {string} userId + * @param {string} careerProfileId + * @param {string} summaryText ← pre-built summary + * @returns {string} ← cached or stored summary + */ +export async function cacheSummary(userId, careerProfileId, summaryText) { + const hash = crypto.createHash("sha1").update(summaryText).digest("hex"); + + // 1. try cache + const [rows] = await pool.query( + `SELECT ctx_text FROM context_cache + WHERE user_id=? AND career_profile_id=? AND ctx_hash=? LIMIT 1`, + [userId, careerProfileId, hash] + ); + if (rows[0]) return rows[0].ctx_text; + + // 2. miss → store + await pool.query( + `REPLACE INTO context_cache + (user_id, career_profile_id, ctx_hash, ctx_text) + VALUES (?,?,?,?)`, + [userId, careerProfileId, hash, summaryText] + ); + return summaryText; +} diff --git a/src/components/CareerCoach.js b/src/components/CareerCoach.js index 44e8594..84e1d58 100644 --- a/src/components/CareerCoach.js +++ b/src/components/CareerCoach.js @@ -21,74 +21,85 @@ Do NOT output milestones JSON.`; /* ---------------------------------------------- Hidden prompts for the quick-action buttons ---------------------------------------------- */ -const QUICK_PROMPTS = { - networking: ` - -Return **ONLY** valid JSON: -TODAY = ${isoToday} -**Every milestone.date must be >= TODAY** +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": "<= 5 words", - "date": "YYYY-MM-DD", - "description": "1–2 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" - } - ] - } - ] -} + "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(); + } -NO extra commentary. The JSON will be stored as milestones.`.trim(), +const QUICK_PROMPTS = { + networking: ({ careerName, goalsText }) => ` +MODE : networking_plan +ROLE : ${careerName} +GOALS: ${goalsText || "N/A"} - 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(), +Create three milestones that expand professional connections. +${planPrompt()} +`.trim(), - aiGrowth: ({ role, skills, goals }) => ` -You are my personal career coach. -Goal ➜ Help me **grow with AI** in the role of “${role}”. + jobSearch: ({ careerName, goalsText }) => ` +MODE : job_search_plan +ROLE : ${careerName} +GOALS: ${goalsText || "N/A"} -Context -• Current skills: ${skills || "N/A"} -• Career goals : ${goals || "N/A"} +Draft three milestones that accelerate the job hunt. +${planPrompt()} +`.trim(), -INSTRUCTIONS -1. List the top 2–3 tasks in this role already touched by AI. -2. For each task: - – How to *collaborate* with AI tools (save time / raise quality) - – One upskilling step that boosts the user’s uniquely-human edge -3. Surface any adjacent, AI-created roles that fit the user. -4. End with a 90-day action plan (bullets, with dates). + aiGrowth: ({ careerName, skillsText = "N/A", goalsText = "N/A" }) => ` + MODE: ai_growth + TODAY = ${isoToday} -Tone: supportive, opportunity-focused. -Length ≤ 400 words. - `.trim(), + 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 constructive feedback, then ask the next question. +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, @@ -127,9 +138,10 @@ useEffect(() => { /* -------------- intro ---------------- */ useEffect(() => { if (!scenarioRow) return; - setMessages([generatePersonalizedIntro()]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scenarioRow?.id]); + setMessages(prev => + prev.length ? prev // keep what we loaded + : [generatePersonalizedIntro()] ); +}, [scenarioRow?.id]); /* ---------- helpers you already had ---------- */ function buildStatusSituationMessage(status, situation, careerName) { @@ -168,8 +180,7 @@ useEffect(() => { nextPart = `I'm not entirely sure of your next direction, but we’ll keep your background in mind.`; } const friendlyNote = ` -Feel free to use AptivaAI however it best suits you—there’s 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. +Feel free to use AptivaAI however it best suits you—there’s 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}`; @@ -194,7 +205,7 @@ I'm here to support you with personalized coaching. What would you like to focus } /* ------------ shared AI caller ------------- */ - async function callAi(updatedHistory) { + async function callAi(updatedHistory, opts = {}) { setLoading(true); try { const payload = { @@ -202,20 +213,29 @@ I'm here to support you with personalized coaching. What would you like to focus financialProfile, scenarioRow, collegeProfile, - chatHistory: updatedHistory + 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 { reply, aiRisk: riskData, createdMilestones = [] } = await res.json(); + 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 = reply.trim().startsWith("{") || reply.trim().startsWith("["); + const isJson = safeReply.trim().startsWith("{") || safeReply.trim().startsWith("["); const friendlyReply = isJson - ? "✅ Got it! I added new milestones to your plan. Check your Milestones tab." - : reply; + ? "✅ Got it! I added new milestones to your plan. Check your Milestones tab." + : safeReply; setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]); @@ -243,59 +263,52 @@ I'm here to support you with personalized coaching. What would you like to focus /* ------------ quick-action buttons ------------- */ function triggerQuickAction(type) { - if (loading) return; + 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 career = scenarioRow?.career_name || "the target role"; - const desc = scenarioRow?.job_description || ""; - const hiddenSystem = { - role: "system", - content: buildInterviewPrompt(career, desc) // ← dynamic prompt - }; - const note = { - role: "assistant", - content: - "Starting mock interview focused on **" + career + "**. Answer each question and I'll give feedback!" - }; - const updated = [...messages, note, hiddenSystem]; + 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; } - /* ---------- NEW: AI-growth quick action ---------- */ - if (type === "aiGrowth") { - const note = { - role : "assistant", - content: "Sure! Let’s map out how you can *partner* with AI in this career…" - }; - const hiddenSystem = { - role : "system", - content: QUICK_PROMPTS.aiGrowth({ - role : scenarioRow?.career_name || "your role", - goals : scenarioRow?.career_goals || "" - }) - }; - const updated = [...messages, note, hiddenSystem]; - setMessages([...messages, note]); - callAi(updated); - return; - } - - /* networking / jobSearch unchanged */ + /* 2) All other quick actions share the same pattern */ const note = { - role: "assistant", - content: - type === "networking" - ? "Sure! Let me create a Networking roadmap for you…" - : "Sure! Let me create a Job-Search roadmap for you…" + 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! Let’s map out how you can *partner* with AI in this career…" + }[type] || "OK!" }; - const hiddenSystem = { role: "system", content: QUICK_PROMPTS[type] }; + + const hiddenSystem = { + role : "system", + content: QUICK_PROMPTS[type]({ + careerName, goalsText, skillsText, avoidList + }) + }; + const updated = [...messages, note, hiddenSystem]; setMessages([...messages, note]); - callAi(updated); + const needsContext = ["networking", "jobSearch", "aiGrowth"].includes(type); + callAi(updated, {forceContext: needsContext}); } + /* ------------ render ------------- */ return (