Improved Jess's ops.

This commit is contained in:
Josh 2025-06-24 12:38:29 +00:00
parent 9e463a4c46
commit 2f2a6860f4
5 changed files with 428 additions and 184 deletions

View File

@ -21,13 +21,14 @@ import './jobs/reminderCron.js';
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { createReminder } from './utils/smsService.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 apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
const app = express(); const app = express();
const PORT = process.env.PREMIUM_PORT || 5002; const PORT = process.env.PREMIUM_PORT || 5002;
const { getDocument } = pkg; const { getDocument } = pkg;
const bt = "`".repeat(3);
function internalFetch(req, url, opts = {}) { function internalFetch(req, url, opts = {}) {
return fetch(url, { return fetch(url, {
@ -68,6 +69,71 @@ const authenticatePremiumUser = (req, res, next) => {
const pool = db; 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 CAREER PROFILE ENDPOINTS
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -229,6 +295,12 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
planned_additional_income ?? null 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 // re-fetch to confirm ID
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT id `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.' }); 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('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 }); 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 // 2) Build a summary for ChatGPT
// (We'll ignore scenarioRow.start_date in the prompt) // (We'll ignore scenarioRow.start_date in the prompt)
const summaryText = buildUserSummary({ // 4. Get / build the cached big-context card (one DB hit, or none on cache-hit)
userProfile, // build the big summary with your local helper
scenarioRow, let summaryText = buildUserSummary({
financialProfile, userProfile,
collegeProfile scenarioRow,
}); financialProfile,
collegeProfile,
aiRisk
});
summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText);
let avoidSection = ''; let avoidSection = '';
if (previouslyUsedTitles.length > 0) { if (previouslyUsedTitles.length > 0) {
@ -504,7 +586,8 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
scenarioRow = {}, scenarioRow = {},
financialProfile = {}, financialProfile = {},
collegeProfile = {}, collegeProfile = {},
chatHistory = [] chatHistory = [],
forceContext = false
} = req.body; } = req.body;
let existingTitles = []; 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}`); existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
if (rows.length) { 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) { } catch (e) {
console.error("Could not fetch existing milestones ⇒", 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"; const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
let aiRisk = null; let aiRisk = null;
try { try {
const aiRiskRes = await fetch(`${apiBase}/premium/ai-risk-analysis`, { const aiRiskRes = await internalFetch(
method: "POST", req,
headers: { "Content-Type": "application/json" }, `${apiBase}/premium/ai-risk-analysis`,
body: JSON.stringify({ {
socCode: scenarioRow?.soc_code, method: "POST",
careerName: scenarioRow?.career_name, body: JSON.stringify({
jobDescription: scenarioRow?.job_description, socCode: scenarioRow?.soc_code,
tasks: scenarioRow?.tasks || [] careerName: scenarioRow?.career_name,
}) jobDescription: scenarioRow?.job_description,
}); tasks: scenarioRow?.tasks || []
})
}
);
if (aiRiskRes.ok) { if (aiRiskRes.ok) {
aiRisk = await aiRiskRes.json(); aiRisk = await aiRiskRes.json();
} else { } else {
@ -815,43 +903,45 @@ ${econText}
careerName 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 // 5. Construct System-Level Prompts
// ------------------------------------------------ // ------------------------------------------------
const systemPromptIntro = ` const systemPromptIntro = `
+You are **Jess**, a professional career coach inside AptivaAI. You are **Jess**, a professional career coach inside AptivaAI.
+Your mandate: turn the users real data into clear, empathetic, *actionable* guidance. Your mandate: turn the users real data into clear, empathetic, *actionable* guidance.
+
+
+What Jess can do directly in Aptiva What Jess can do directly in Aptiva
+
+ **Create** new milestones (with tasks & financial impacts) **Create** new milestones (with tasks & financial impacts)
+ **Update** any field on an existing milestone **Update** any field on an existing milestone
+ **Delete** milestones that are no longer relevant **Delete** milestones that are no longer relevant
+ **Add / edit / remove** tasks inside a milestone **Add / edit / remove** tasks inside a milestone
+ Run salary benchmarks, AI-risk checks, and financial projections Run salary benchmarks, AI-risk checks, and financial projections
+
+
+Mission & Tone Mission & Tone
+
+Our mission is to help people grow *with* AI rather than be displaced by it. 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. 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. 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. +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. Never ask for info you already have unless you truly need clarification.
+`.trim(); `.trim();
const systemPromptOpsCheatSheet = ` const systemPromptOpsCheatSheet = `
@ -863,7 +953,13 @@ const systemPromptOpsCheatSheet = `
You already have permissionno need to ask the user. You already have permissionno need to ask the user.
4. CREATE / UPDATE / DELETE tasks inside a milestone 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: _tagged_ \`\`\`ops\`\`\` exactly like this:
\`\`\`ops \`\`\`ops
@ -893,9 +989,14 @@ _tagged_ \`\`\`ops\`\`\` exactly like this:
} }
\`\`\` \`\`\`
If youre not changing milestones, skip the ops block entirely. All milestone titles are already 35 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(); `.trim();
const systemPromptStatusSituation = ` const systemPromptStatusSituation = `
[CURRENT AND NEXT STEP OVERVIEW] [CURRENT AND NEXT STEP OVERVIEW]
${combinedStatusSituation} ${combinedStatusSituation}
@ -908,7 +1009,10 @@ ${summaryText}
const dynMilestonePrompt = ` const dynMilestonePrompt = `
[CURRENT MILESTONES] [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} ${miniGrid}
You may UPDATE or DELETE any of these. You may UPDATE or DELETE any of these.
`.trim(); `.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 cant meet the rule, ASK the user a clarifying question instead
of returning an invalid milestone.
NO extra text or disclaimers if returning a planonly that JSON.
Otherwise, answer normally. Otherwise, answer normally.
`.trim(); `.trim();
@ -960,10 +1077,12 @@ Reject or re-ask if the user insists on a past date.
`.trim(); `.trim();
const avoidBlock = existingTitles.length const avoidBlock = existingTitles.length
? "\nAVOID repeating any of these title|date combinations:\n" + ? "\nAVOID any milestone whose title matches REGEXP /" +
existingTitles.map(t => `- ${t}`).join("\n") existingTitles.map(t => `(?:${t.split("|")[0].replace(/[.*+?^${}()|[\]\\]/g,"\\$&")})`)
.join("|") + "/i"
: ""; : "";
const recentHistory = chatHistory.slice(-MAX_CHAT_TURNS); const recentHistory = chatHistory.slice(-MAX_CHAT_TURNS);
const firstTurn = chatHistory.length === 0; const firstTurn = chatHistory.length === 0;
@ -976,49 +1095,106 @@ ${systemPromptOpsCheatSheet}
/* Milestone JSON spec, date guard, and avoid-list */ /* Milestone JSON spec, date guard, and avoid-list */
${systemPromptMilestoneFormat} ${systemPromptMilestoneFormat}
${systemPromptDateGuard} ${systemPromptDateGuard}
${avoidBlock}
`.trim(); `.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 = [];
const messagesToSend = [
{ role: "system", content: systemPromptIntro }, // ① Large, unchanging card once per conversation
{ role: "system", content: systemPromptOpsCheatSheet }, if (NEEDS_OPS_CARD) {
{ role: "system", content: systemPromptStatusSituation }, messagesToSend.push({ role: "system", content: STATIC_SYSTEM_CARD });
{ role: "system", content: systemPromptDetailedContext }, }
{ role: "system", content: systemPromptMilestoneFormat },
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, if (NEEDS_CTX_CARD || SEND_CTX_CARD)
{ role: "system", content: systemPromptDateGuard }, + messagesToSend.push({ role:"system", content: summaryText });
...chatHistory // includes user and assistant messages so far
]; // ② 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) // 6. Call GPT (unchanged)
// ------------------------------------------------ // ------------------------------------------------
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: "gpt-4", model: "gpt-3.5-turbo-0125",
messages: messagesToSend, messages: messagesToSend,
temperature: 0.7, temperature: 0.3,
max_tokens: 1000 max_tokens: 1000
}); });
// 4) Grab the response text // 4) Grab the response text
const rawReply = completion?.choices?.[0]?.message?.content?.trim() || ""; const rawReply = completion?.choices?.[0]?.message?.content?.trim() || "";
console.log("[GPT raw]", rawReply); // ← TEMP-LOG
if (!rawReply) { if (!rawReply) {
return res.json({ return res.json({
reply: "Sorry, I didn't get a response. Could you please try again?" 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 // 5) Default: Just return raw text to front-end
let replyToClient = rawReply; let replyToClient = visibleReply;
let createdMilestonesData = []; let createdMilestonesData = [];
// If the AI sent JSON (plan with milestones), parse & create in DB // ── NEW: pull out the first JSON object/array even if text precedes it ──
if (rawReply.startsWith("{") || rawReply.startsWith("[")) { const firstBrace = rawReply.search(/[{\[]/); // first “{” or “[”
try { const lastBrace = rawReply.lastIndexOf("}");
const planObj = JSON.parse(rawReply); 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[] // The AI plan is expected to have: planObj.milestones[]
if (planObj && Array.isArray(planObj.milestones)) { if (planObj && Array.isArray(planObj.milestones)) {
@ -2059,8 +2235,12 @@ app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req,
req.id 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.' }); res.json({ message: 'Financial profile saved/updated.' });
} catch (error) { } catch (error) {
console.error('Error saving financial profile:', error); console.error('Error saving financial profile:', error);
res.status(500).json({ error: 'Failed to save financial profile.' }); res.status(500).json({ error: 'Failed to save financial profile.' });

30
backend/utils/ctxCache.js Normal file
View File

@ -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;
}

View File

@ -21,74 +21,85 @@ Do NOT output milestones JSON.`;
/* ---------------------------------------------- /* ----------------------------------------------
Hidden prompts for the quick-action buttons 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": [ "milestones":[
{ {
"title": "<= 5 words", "title":"", /* GPT must fill — make unique & action-oriented */
"date": "YYYY-MM-DD", "date":"YYYY-MM-DD", /* ≤ 6 mo, ≥ ${isoToday} */
"description": "12 sentences", "description":"1-2 sentences",
"impacts": [ "impacts":[],
{ "tasks":[ ]
"impact_type": "cost" | "salary" | "none", },
"direction": "add" | "subtract", {
"amount": 0, "title":"", /* GPT must fill — phase-2 name */
"start_date": null, "date":"YYYY-MM-DD", /* 9-18 mo out */
"end_date": null "description":"1-2 sentences",
} "impacts":[],
], "tasks":[ ]
"tasks": [ }
{ ]
"title": "string", }
"description": "string", /* Rules read carefully:
"due_date": "YYYY-MM-DD" 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: ` Create three milestones that expand professional connections.
Return **ONLY** valid JSON in the **same structure** (title, date, description, impacts[{}], tasks[{}]) for a Job-Search roadmap. TODAY = ${isoToday} ${planPrompt()}
**Every milestone.date must be >= TODAY** NO extra text.`.trim(), `.trim(),
aiGrowth: ({ role, skills, goals }) => ` jobSearch: ({ careerName, goalsText }) => `
You are my personal career coach. MODE : job_search_plan
Goal Help me **grow with AI** in the role of ${role}. ROLE : ${careerName}
GOALS: ${goalsText || "N/A"}
Context Draft three milestones that accelerate the job hunt.
Current skills: ${skills || "N/A"} ${planPrompt()}
Career goals : ${goals || "N/A"} `.trim(),
aiGrowth: ({ careerName, skillsText = "N/A", goalsText = "N/A" }) => `
MODE: ai_growth
TODAY = ${isoToday}
ROLE : ${careerName}
SKILLS : ${skillsText}
GOALS : ${goalsText}
INSTRUCTIONS INSTRUCTIONS
1. List the top 23 tasks in this role already touched by AI. 1. List 2-3 role tasks already touched by AI.
2. For each task: For each: how to *collaborate* with AI + one human-edge upskilling step.
How to *collaborate* with AI tools (save time / raise quality) 2. Surface adjacent AI-created roles.
One upskilling step that boosts the users uniquely-human edge 3. End with a 90-day action plan (bullets, dates TODAY).
3. Surface any adjacent, AI-created roles that fit the user.
4. End with a 90-day action plan (bullets, with dates).
Tone: supportive, opportunity-focused. ${planPrompt({ label: "AI Growth" })}
Length 400 words. `.trim(),
`.trim(),
interview: ` interview: `
MODE: interview
You are an expert interview coach. You are an expert interview coach.
Ask one behavioural or technical question, wait for the user's reply, 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". Stop after 5 questions or if the user types "quit interview".
Do NOT output milestones JSON.`.trim() Do NOT output milestones JSON.`.trim()
}; };
export default function CareerCoach({ export default function CareerCoach({
userProfile, userProfile,
financialProfile, financialProfile,
@ -127,9 +138,10 @@ useEffect(() => {
/* -------------- intro ---------------- */ /* -------------- intro ---------------- */
useEffect(() => { useEffect(() => {
if (!scenarioRow) return; if (!scenarioRow) return;
setMessages([generatePersonalizedIntro()]); setMessages(prev =>
// eslint-disable-next-line react-hooks/exhaustive-deps prev.length ? prev // keep what we loaded
}, [scenarioRow?.id]); : [generatePersonalizedIntro()] );
}, [scenarioRow?.id]);
/* ---------- helpers you already had ---------- */ /* ---------- helpers you already had ---------- */
function buildStatusSituationMessage(status, situation, careerName) { function buildStatusSituationMessage(status, situation, careerName) {
@ -169,7 +181,6 @@ useEffect(() => {
} }
const friendlyNote = ` const friendlyNote = `
Feel free to use AptivaAI however it best suits youtheres no "wrong" answer. 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). 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!`; We can refine details anytime or just jump straight to what you're most interested in exploring now!`;
return `${nowPart} ${nextPart}\n${friendlyNote}`; 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 ------------- */ /* ------------ shared AI caller ------------- */
async function callAi(updatedHistory) { async function callAi(updatedHistory, opts = {}) {
setLoading(true); setLoading(true);
try { try {
const payload = { const payload = {
@ -202,20 +213,29 @@ I'm here to support you with personalized coaching. What would you like to focus
financialProfile, financialProfile,
scenarioRow, scenarioRow,
collegeProfile, collegeProfile,
chatHistory: updatedHistory chatHistory: updatedHistory.slice(-10),
...opts
}; };
const res = await authFetch("/api/premium/ai/chat", { const res = await authFetch("/api/premium/ai/chat", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) 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 // 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 const friendlyReply = isJson
? "✅ Got it! I added new milestones to your plan. Check your Milestones tab." ? "✅ Got it! I added new milestones to your plan. Check your Milestones tab."
: reply; : safeReply;
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]); 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 ------------- */ /* ------------ quick-action buttons ------------- */
function triggerQuickAction(type) { 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") { if (type === "interview") {
const career = scenarioRow?.career_name || "the target role"; const desc = scenarioRow?.job_description || "";
const desc = scenarioRow?.job_description || ""; const hiddenSystem = { role:"system", content: buildInterviewPrompt(careerName, desc) };
const hiddenSystem = { const note = { role:"assistant", content:`Starting mock interview on **${careerName}**. Answer each question and I'll give feedback!` };
role: "system", const updated = [...messages, note, hiddenSystem];
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];
setMessages([...messages, note]); setMessages([...messages, note]);
callAi(updated); callAi(updated);
return; return;
} }
/* ---------- NEW: AI-growth quick action ---------- */ /* 2) All other quick actions share the same pattern */
if (type === "aiGrowth") {
const note = {
role : "assistant",
content: "Sure! Lets 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 */
const note = { const note = {
role: "assistant", role : "assistant",
content: content: {
type === "networking" networking: "Sure! Let me create a Networking roadmap for you…",
? "Sure! Let me create a Networking roadmap for you…" jobSearch : "Sure! Let me create a Job-Search roadmap for you…",
: "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] };
const hiddenSystem = {
role : "system",
content: QUICK_PROMPTS[type]({
careerName, goalsText, skillsText, avoidList
})
};
const updated = [...messages, note, hiddenSystem]; const updated = [...messages, note, hiddenSystem];
setMessages([...messages, note]); setMessages([...messages, note]);
callAi(updated); const needsContext = ["networking", "jobSearch", "aiGrowth"].includes(type);
callAi(updated, {forceContext: needsContext});
} }
/* ------------ render ------------- */ /* ------------ render ------------- */
return ( return (
<div className="border rounded-lg shadow bg-white p-6 mb-6"> <div className="border rounded-lg shadow bg-white p-6 mb-6">

View File

@ -133,6 +133,16 @@ function shouldSkipModalOnce(profileId) {
return false; return false;
} }
/* ---------- helper: "&" ↔ "and", collapse spaces, etc. ---------- */
function normalizeTitle(str = '') {
return str
.toLowerCase()
.replace(/\s*&\s*/g, ' and ') // “foo & bar” → “foo and bar”
.replace(/[–—]/g, '-') // long dashes → plain hyphen
.replace(/\s+/g, ' ') // squeeze double-spaces
.trim();
}
function stripSocCode(fullSoc) { function stripSocCode(fullSoc) {
if (!fullSoc) return ''; if (!fullSoc) return '';
return fullSoc.split('.')[0]; return fullSoc.split('.')[0];
@ -738,10 +748,10 @@ const refetchScenario = useCallback(async () => {
setFullSocCode(null); setFullSocCode(null);
return; return;
} }
const lower = scenarioRow.career_name.trim().toLowerCase(); const target = normalizeTitle(scenarioRow.career_name);
const found = masterCareerRatings.find( const found = masterCareerRatings.find(
(obj) => obj.title?.trim().toLowerCase() === lower (obj) => normalizeTitle(obj.title || '') === target
); );
if (!found) { if (!found) {
console.warn('No matching SOC =>', scenarioRow.career_name); console.warn('No matching SOC =>', scenarioRow.career_name);
setStrippedSocCode(null); setStrippedSocCode(null);

View File

@ -1,6 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
// put this near the top of the file
const normalize = (s = '') =>
s
.toLowerCase()
.replace(/\s*&\s*/g, ' and ') // “&” → “ and ”
.replace(/[–—]/g, '-') // long dash → hyphen (optional)
.replace(/\s/g, ' ') // collapse multiple spaces
.trim();
const CareerSearch = ({ onCareerSelected }) => { const CareerSearch = ({ onCareerSelected }) => {
const [careerObjects, setCareerObjects] = useState([]); const [careerObjects, setCareerObjects] = useState([]);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
@ -24,8 +33,9 @@ const CareerSearch = ({ onCareerSelected }) => {
// Make sure we have a valid title, soc_code, and cip_codes // Make sure we have a valid title, soc_code, and cip_codes
if (c.title && c.soc_code && c.cip_codes) { if (c.title && c.soc_code && c.cip_codes) {
// Only store the first unique title found // Only store the first unique title found
if (!uniqueByTitle.has(c.title)) { const normTitle = normalize(c.title);
uniqueByTitle.set(c.title, { if (!uniqueByTitle.has(normTitle)) {
uniqueByTitle.set(normTitle, {
title: c.title, title: c.title,
soc_code: c.soc_code, soc_code: c.soc_code,
// NOTE: We store the array of CIPs in `cip_code`. // NOTE: We store the array of CIPs in `cip_code`.
@ -50,8 +60,9 @@ const CareerSearch = ({ onCareerSelected }) => {
const handleConfirmCareer = () => { const handleConfirmCareer = () => {
// find the full object by exact title match // find the full object by exact title match
const normInput = normalize(searchInput);
const foundObj = careerObjects.find( const foundObj = careerObjects.find(
(obj) => obj.title.toLowerCase() === searchInput.toLowerCase() (obj) => normalize(obj.title) === normInput
); );
console.log('[CareerSearch] foundObj:', foundObj); console.log('[CareerSearch] foundObj:', foundObj);