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 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({
// 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
});
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`, {
const aiRiskRes = await internalFetch(
req,
`${apiBase}/premium/ai-risk-analysis`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
socCode: scenarioRow?.soc_code,
careerName: scenarioRow?.career_name,
jobDescription: scenarioRow?.job_description,
tasks: scenarioRow?.tasks || []
})
});
}
);
if (aiRiskRes.ok) {
aiRisk = await aiRiskRes.json();
} else {
@ -815,43 +903,45 @@ ${econText}
careerName
);
// ------------------------------------------------
// 4. Build Additional Context Summary
// ------------------------------------------------
const summaryText = buildUserSummary({
userProfile,
// 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 users 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 users 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 permissionno 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 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();
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 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.
`.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 },
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: systemPromptDetailedContext },
{ role: "system", content: systemPromptMilestoneFormat },
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock },
{ role: "system", content: systemPromptDateGuard },
...chatHistory // includes user and assistant messages so far
];
{ 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("[")) {
// ── 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(rawReply);
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.' });

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
---------------------------------------------- */
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",
"date": "YYYY-MM-DD",
"description": "12 sentences",
"impacts": [
"title":"", /* GPT must fill — make unique & action-oriented */
"date":"YYYY-MM-DD", /* ≤ 6 mo, ≥ ${isoToday} */
"description":"1-2 sentences",
"impacts":[],
"tasks":[ ]
},
{
"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"
"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(),
aiGrowth: ({ careerName, skillsText = "N/A", goalsText = "N/A" }) => `
MODE: ai_growth
TODAY = ${isoToday}
ROLE : ${careerName}
SKILLS : ${skillsText}
GOALS : ${goalsText}
INSTRUCTIONS
1. List the top 23 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 users 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).
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).
Tone: supportive, opportunity-focused.
Length 400 words.
`.trim(),
${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) {
@ -169,7 +181,6 @@ useEffect(() => {
}
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!`;
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;
: 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 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") {
/* 2) All other quick actions share the same pattern */
const note = {
role : "assistant",
content: "Sure! Lets map out how you can *partner* with AI in this career…"
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.aiGrowth({
role : scenarioRow?.career_name || "your role",
goals : scenarioRow?.career_goals || ""
content: QUICK_PROMPTS[type]({
careerName, goalsText, skillsText, avoidList
})
};
const updated = [...messages, note, hiddenSystem];
setMessages([...messages, note]);
callAi(updated);
return;
}
/* networking / jobSearch unchanged */
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…"
};
const hiddenSystem = { role: "system", content: QUICK_PROMPTS[type] };
const updated = [...messages, note, hiddenSystem];
setMessages([...messages, note]);
callAi(updated);
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">

View File

@ -133,6 +133,16 @@ function shouldSkipModalOnce(profileId) {
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) {
if (!fullSoc) return '';
return fullSoc.split('.')[0];
@ -738,9 +748,9 @@ const refetchScenario = useCallback(async () => {
setFullSocCode(null);
return;
}
const lower = scenarioRow.career_name.trim().toLowerCase();
const target = normalizeTitle(scenarioRow.career_name);
const found = masterCareerRatings.find(
(obj) => obj.title?.trim().toLowerCase() === lower
(obj) => normalizeTitle(obj.title || '') === target
);
if (!found) {
console.warn('No matching SOC =>', scenarioRow.career_name);

View File

@ -1,6 +1,15 @@
import React, { useEffect, useState } from 'react';
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 [careerObjects, setCareerObjects] = useState([]);
const [searchInput, setSearchInput] = useState('');
@ -24,8 +33,9 @@ const CareerSearch = ({ onCareerSelected }) => {
// Make sure we have a valid title, soc_code, and cip_codes
if (c.title && c.soc_code && c.cip_codes) {
// Only store the first unique title found
if (!uniqueByTitle.has(c.title)) {
uniqueByTitle.set(c.title, {
const normTitle = normalize(c.title);
if (!uniqueByTitle.has(normTitle)) {
uniqueByTitle.set(normTitle, {
title: c.title,
soc_code: c.soc_code,
// NOTE: We store the array of CIPs in `cip_code`.
@ -50,8 +60,9 @@ const CareerSearch = ({ onCareerSelected }) => {
const handleConfirmCareer = () => {
// find the full object by exact title match
const normInput = normalize(searchInput);
const foundObj = careerObjects.find(
(obj) => obj.title.toLowerCase() === searchInput.toLowerCase()
(obj) => normalize(obj.title) === normInput
);
console.log('[CareerSearch] foundObj:', foundObj);