Improved Jess's ops.
This commit is contained in:
parent
9e463a4c46
commit
2f2a6860f4
@ -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,13 +423,18 @@ 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)
|
||||||
|
// build the big summary with your local helper
|
||||||
|
let summaryText = buildUserSummary({
|
||||||
userProfile,
|
userProfile,
|
||||||
scenarioRow,
|
scenarioRow,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
collegeProfile
|
collegeProfile,
|
||||||
|
aiRisk
|
||||||
});
|
});
|
||||||
|
|
||||||
|
summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText);
|
||||||
|
|
||||||
let avoidSection = '';
|
let avoidSection = '';
|
||||||
if (previouslyUsedTitles.length > 0) {
|
if (previouslyUsedTitles.length > 0) {
|
||||||
avoidSection = `\nDO NOT repeat the following milestone titles:\n${previouslyUsedTitles
|
avoidSection = `\nDO NOT repeat the following milestone titles:\n${previouslyUsedTitles
|
||||||
@ -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(
|
||||||
|
req,
|
||||||
|
`${apiBase}/premium/ai-risk-analysis`,
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
socCode: scenarioRow?.soc_code,
|
socCode: scenarioRow?.soc_code,
|
||||||
careerName: scenarioRow?.career_name,
|
careerName: scenarioRow?.career_name,
|
||||||
jobDescription: scenarioRow?.job_description,
|
jobDescription: scenarioRow?.job_description,
|
||||||
tasks: scenarioRow?.tasks || []
|
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
|
// 4. Build / fetch the cached summary (auto-rebuild if missing)
|
||||||
// ------------------------------------------------
|
let summaryText = buildUserSummary({
|
||||||
const summaryText = buildUserSummary({
|
userId : req.id,
|
||||||
userProfile,
|
|
||||||
scenarioRow,
|
scenarioRow,
|
||||||
|
userProfile,
|
||||||
financialProfile,
|
financialProfile,
|
||||||
collegeProfile,
|
collegeProfile,
|
||||||
aiRisk
|
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 user’s real data into clear, empathetic, *actionable* guidance.
|
Your mandate: turn the user’s 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 permission—no need to ask the user.
|
• You already have permission—no 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 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();
|
`.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 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.
|
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) {
|
||||||
|
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: systemPromptStatusSituation },
|
||||||
{ role: "system", content: systemPromptDetailedContext },
|
{ role: "system", content: dynMilestonePrompt } // <-- grid replaces two old lines
|
||||||
{ role: "system", content: systemPromptMilestoneFormat },
|
);
|
||||||
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock },
|
|
||||||
{ role: "system", content: systemPromptDateGuard },
|
// ③ Recent conversational context
|
||||||
...chatHistory // includes user and assistant messages so far
|
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 “[”
|
||||||
|
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 {
|
try {
|
||||||
const planObj = JSON.parse(rawReply);
|
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
30
backend/utils/ctxCache.js
Normal 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;
|
||||||
|
}
|
@ -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": "1–2 sentences",
|
"description":"1-2 sentences",
|
||||||
"impacts": [
|
"impacts":[],
|
||||||
|
"tasks":[ … ]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"impact_type": "cost" | "salary" | "none",
|
"title":"", /* GPT must fill — phase-2 name */
|
||||||
"direction": "add" | "subtract",
|
"date":"YYYY-MM-DD", /* 9-18 mo out */
|
||||||
"amount": 0,
|
"description":"1-2 sentences",
|
||||||
"start_date": null,
|
"impacts":[],
|
||||||
"end_date": null
|
"tasks":[ … ]
|
||||||
}
|
|
||||||
],
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"title": "string",
|
|
||||||
"description": "string",
|
|
||||||
"due_date": "YYYY-MM-DD"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
/* 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: `
|
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 2–3 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 user’s 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,8 +138,9 @@ 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
|
||||||
|
: [generatePersonalizedIntro()] );
|
||||||
}, [scenarioRow?.id]);
|
}, [scenarioRow?.id]);
|
||||||
|
|
||||||
/* ---------- helpers you already had ---------- */
|
/* ---------- helpers you already had ---------- */
|
||||||
@ -169,7 +181,6 @@ useEffect(() => {
|
|||||||
}
|
}
|
||||||
const friendlyNote = `
|
const friendlyNote = `
|
||||||
Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer.
|
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.
|
|
||||||
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,58 +263,51 @@ 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 = {
|
const hiddenSystem = { role:"system", content: buildInterviewPrompt(careerName, desc) };
|
||||||
role: "system",
|
const note = { role:"assistant", content:`Starting mock interview on **${careerName}**. Answer each question and I'll give feedback!` };
|
||||||
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 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 = {
|
const note = {
|
||||||
role : "assistant",
|
role : "assistant",
|
||||||
content: "Sure! Let’s 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! Let’s map out how you can *partner* with AI in this career…"
|
||||||
|
}[type] || "OK!"
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiddenSystem = {
|
const hiddenSystem = {
|
||||||
role : "system",
|
role : "system",
|
||||||
content: QUICK_PROMPTS.aiGrowth({
|
content: QUICK_PROMPTS[type]({
|
||||||
role : scenarioRow?.career_name || "your role",
|
careerName, goalsText, skillsText, avoidList
|
||||||
goals : scenarioRow?.career_goals || ""
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
return;
|
callAi(updated, {forceContext: needsContext});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------ render ------------- */
|
/* ------------ render ------------- */
|
||||||
return (
|
return (
|
||||||
|
@ -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,9 +748,9 @@ 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);
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user