From 7a31b722c5a0afd9a825be2614fbe27e44816ce5 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 29 May 2025 13:24:35 +0000 Subject: [PATCH] AI Risk assessment in CareerExplorer implemented --- backend/server3.js | 62 +++++- src/components/CareerExplorer.js | 315 +++++++++++++++++-------------- src/components/CareerModal.js | 35 +--- user_profile.db | Bin 135168 -> 135168 bytes 4 files changed, 238 insertions(+), 174 deletions(-) diff --git a/backend/server3.js b/backend/server3.js index 32b6458..b9bfe92 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -585,7 +585,7 @@ app.post('/api/premium/milestone/convert-ai', authenticatePremiumUser, async (re }); /*************************************************** - AI CAREER RISK ANALYSIS ENDPOINT + AI CAREER RISK ANALYSIS ENDPOINTS ****************************************************/ // server3.js app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, res) => { @@ -671,6 +671,66 @@ app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, r } }); +app.post('/api/public/ai-risk-analysis', async (req, res) => { + try { + const { + socCode, + careerName, + jobDescription, + tasks = [] + } = req.body; + + if (!socCode || !careerName) { + return res.status(400).json({ error: 'socCode and careerName are required.' }); + } + + const prompt = ` + The user has a career named: ${careerName} + Description: ${jobDescription} + Tasks: ${tasks.join('; ')} + + Provide AI automation risk analysis for the next 10 years. + Return JSON exactly in this format: + + { + "riskLevel": "Low|Moderate|High", + "reasoning": "Short explanation (< 50 words)." + } + `; + + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + const completion = await openai.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: prompt }], + temperature: 0.3, + max_tokens: 200, + }); + + const aiText = completion?.choices?.[0]?.message?.content?.trim() || ''; + let parsed; + try { + parsed = JSON.parse(aiText); + } catch (err) { + console.error('Error parsing AI JSON:', err); + return res.status(500).json({ error: 'Invalid AI JSON response.' }); + } + const { riskLevel, reasoning } = parsed; + + res.json({ + socCode, + careerName, + jobDescription, + tasks, + riskLevel, + reasoning + }); + + } catch (err) { + console.error('Error in public AI risk analysis:', err); + res.status(500).json({ error: 'AI risk analysis failed.' }); + } +}); + /* ------------------------------------------------------------------ MILESTONE ENDPOINTS ------------------------------------------------------------------ */ diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 7883f05..4a2e063 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -285,159 +285,186 @@ function CareerExplorer() { // ------------------------------------------------------ // handleCareerClick (detail fetch for CIP, Salary, etc.) // ------------------------------------------------------ - const handleCareerClick = useCallback( - async (career) => { - console.log('[handleCareerClick] career =>', career); - const socCode = career.code; - setSelectedCareer(career); - setError(null); - setCareerDetails(null); - setSalaryData([]); - setEconomicProjections({}); + const handleCareerClick = useCallback( + async (career) => { + console.log('[handleCareerClick] career object:', JSON.stringify(career, null, 2)); - if (!socCode) { - console.error('SOC Code is missing'); - setError('SOC Code is missing'); - setLoading(false); - return; - } + const socCode = career.code; + setSelectedCareer(career); + setError(null); + setCareerDetails(null); + setSalaryData([]); + setEconomicProjections({}); + setLoading(true); - try { - // CIP fetch - const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`); - if (!cipResponse.ok) { - setError( - `We're sorry, but specific details for "${career.title}" are not available at this time.` - ); - setCareerDetails({ - error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, - }); - setLoading(false); - return; - } - const { cipCode } = await cipResponse.json(); - const cleanedCipCode = cipCode.replace('.', '').slice(0, 4); + if (!socCode) { + console.error('SOC Code is missing'); + setError('SOC Code is missing'); + setLoading(false); + return; + } - // Job details - const jobDetailsResponse = await fetch( - `${apiUrl}/onet/career-description/${socCode}` + try { + // 1) CIP fetch + const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`); + if (!cipResponse.ok) { + setError( + `We're sorry, but specific details for "${career.title}" are not available at this time.` ); - if (!jobDetailsResponse.ok) { - setCareerDetails({ - error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, - }); - setLoading(false); - return; - } - const { description, tasks } = await jobDetailsResponse.json(); - - // Salary - let salaryResponse; - try { - salaryResponse = await axios.get(`${apiUrl}/salary`, { - params: { socCode: socCode.split('.')[0], area: areaTitle }, - }); - } catch (error) { - salaryResponse = { data: {} }; - } - - const sData = salaryResponse.data || {}; - const salaryDataPoints = - sData && Object.keys(sData).length > 0 - ? [ - { - percentile: '10th Percentile', - regionalSalary: parseInt( - sData.regional?.regional_PCT10, - 10 - ) || 0, - nationalSalary: parseInt( - sData.national?.national_PCT10, - 10 - ) || 0, - }, - { - percentile: '25th Percentile', - regionalSalary: parseInt( - sData.regional?.regional_PCT25, - 10 - ) || 0, - nationalSalary: parseInt( - sData.national?.national_PCT25, - 10 - ) || 0, - }, - { - percentile: 'Median', - regionalSalary: parseInt( - sData.regional?.regional_MEDIAN, - 10 - ) || 0, - nationalSalary: parseInt( - sData.national?.national_MEDIAN, - 10 - ) || 0, - }, - { - percentile: '75th Percentile', - regionalSalary: parseInt( - sData.regional?.regional_PCT75, - 10 - ) || 0, - nationalSalary: parseInt( - sData.national?.national_PCT75, - 10 - ) || 0, - }, - { - percentile: '90th Percentile', - regionalSalary: parseInt( - sData.regional?.regional_PCT90, - 10 - ) || 0, - nationalSalary: parseInt( - sData.national?.national_PCT90, - 10 - ) || 0, - }, - ] - : []; - - // Economic - const fullStateName = getFullStateName(userState); - let economicResponse = { data: {} }; - try { - economicResponse = await axios.get( - `${apiUrl}/projections/${socCode.split('.')[0]}`, - { - params: { state: fullStateName }, - } - ); - } catch (error) { - economicResponse = { data: {} }; - } - - // Build final details - const updatedCareerDetails = { - ...career, - jobDescription: description, - tasks, - salaryData: salaryDataPoints, - economicProjections: economicResponse.data || {}, - }; - - setCareerDetails(updatedCareerDetails); - } catch (error) { - console.error('Error processing career click:', error.message); setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, }); - } finally { setLoading(false); + return; } - }, - [userState, apiUrl, areaTitle] - ); + const { cipCode } = await cipResponse.json(); + const cleanedCipCode = cipCode.replace('.', '').slice(0, 4); + + // 2) Job details (description + tasks) + const jobDetailsResponse = await fetch( + `${apiUrl}/onet/career-description/${socCode}` + ); + if (!jobDetailsResponse.ok) { + setCareerDetails({ + error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, + }); + setLoading(false); + return; + } + const { description, tasks } = await jobDetailsResponse.json(); + + // 3) Salary data + let salaryResponse; + try { + salaryResponse = await axios.get(`${apiUrl}/salary`, { + params: { socCode: socCode.split('.')[0], area: areaTitle }, + }); + } catch (error) { + salaryResponse = { data: {} }; + } + + const sData = salaryResponse.data || {}; + const salaryDataPoints = + sData && Object.keys(sData).length > 0 + ? [ + { + percentile: '10th Percentile', + regionalSalary: parseInt(sData.regional?.regional_PCT10, 10) || 0, + nationalSalary: parseInt(sData.national?.national_PCT10, 10) || 0, + }, + { + percentile: '25th Percentile', + regionalSalary: parseInt(sData.regional?.regional_PCT25, 10) || 0, + nationalSalary: parseInt(sData.national?.national_PCT25, 10) || 0, + }, + { + percentile: 'Median', + regionalSalary: parseInt(sData.regional?.regional_MEDIAN, 10) || 0, + nationalSalary: parseInt(sData.national?.national_MEDIAN, 10) || 0, + }, + { + percentile: '75th Percentile', + regionalSalary: parseInt(sData.regional?.regional_PCT75, 10) || 0, + nationalSalary: parseInt(sData.national?.national_PCT75, 10) || 0, + }, + { + percentile: '90th Percentile', + regionalSalary: parseInt(sData.regional?.regional_PCT90, 10) || 0, + nationalSalary: parseInt(sData.national?.national_PCT90, 10) || 0, + }, + ] + : []; + + // 4) Economic Projections + const fullStateName = getFullStateName(userState); // your helper + let economicResponse = { data: {} }; + try { + economicResponse = await axios.get( + `${apiUrl}/projections/${socCode.split('.')[0]}`, + { + params: { state: fullStateName }, + } + ); + } catch (error) { + economicResponse = { data: {} }; + } + + // ---------------------------------------------------- + // 5) AI RISK ANALYSIS LOGIC + // Attempt to retrieve from server2 first; + // if not found => call server3 => store in server2. + // ---------------------------------------------------- + let aiRisk = null; + const strippedSoc = socCode.split('.')[0]; + + try { + // Check local DB first (SQLite -> server2) + const localRiskRes = await axios.get(`${apiUrl}/ai-risk/${strippedSoc}`); + aiRisk = localRiskRes.data; + } catch (err) { + // If 404, we call server3's ChatGPT route at the SAME base url + if (err.response && err.response.status === 404) { + try { + const aiRes = await axios.post(`${apiUrl}/public/ai-risk-analysis`, { + socCode: strippedSoc, + careerName: career.title, + jobDescription: description, + tasks, + }); + + const { riskLevel, reasoning } = aiRes.data; + + // store it back in server2 to avoid repeated GPT calls + await axios.post(`${apiUrl}/ai-risk`, { + socCode: strippedSoc, + careerName: aiRes.data.careerName, + jobDescription: aiRes.data.jobDescription, + tasks: aiRes.data.tasks, + riskLevel: aiRes.data.riskLevel, + reasoning: aiRes.data.reasoning, + }); + + // build final object + aiRisk = { + socCode: strippedSoc, + careerName: career.title, + jobDescription: description, + tasks, + riskLevel, + reasoning, + }; + } catch (err2) { + console.error('Error calling server3 or storing AI risk:', err2); + // fallback + } + } else { + console.error('Error fetching AI risk from server2:', err); + } + } + + // 6) Build final details object + const updatedCareerDetails = { + ...career, + jobDescription: description, + tasks, + salaryData: salaryDataPoints, + economicProjections: economicResponse.data || {}, + aiRisk, // <--- Now we have it attached + }; + + setCareerDetails(updatedCareerDetails); + + } catch (error) { + console.error('Error processing career click:', error.message); + setCareerDetails({ + error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, + }); + } finally { + setLoading(false); + } + }, + [userState, apiUrl, areaTitle] +); // ------------------------------------------------------ // handleCareerFromSearch diff --git a/src/components/CareerModal.js b/src/components/CareerModal.js index c065272..8fc7892 100644 --- a/src/components/CareerModal.js +++ b/src/components/CareerModal.js @@ -3,37 +3,12 @@ import axios from 'axios'; const apiUrl = process.env.REACT_APP_API_URL; + function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { const [error, setError] = useState(null); - const [aiRisk, setAiRisk] = useState(null); // <-- store AI risk data const [loadingRisk, setLoadingRisk] = useState(false); - - // Fetch AI risk whenever we have a valid SOC code from `career.code` - useEffect(() => { - if (!career?.code) return; - - // e.g. "15-1231.00" => strip down to "15-1231" if needed - const strippedSoc = career.code.split('.')[0]; - setLoadingRisk(true); - - axios - .get(`${apiUrl}/ai-risk/${strippedSoc}`) - .then((res) => { - setAiRisk(res.data); - }) - .catch((err) => { - if (err?.response?.status === 404) { - // no AI risk data - setAiRisk(null); - } else { - console.error('Error fetching AI risk:', err); - setError('Failed to load AI risk analysis.'); - } - }) - .finally(() => { - setLoadingRisk(false); - }); - }, [career, apiUrl]); + const aiRisk = careerDetails?.aiRisk || null; + console.log('CareerModal props:', { career, careerDetails, aiRisk }); @@ -76,6 +51,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { return 1; }; + return (
@@ -86,10 +62,11 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {

{careerDetails.title}

+ {/* AI RISK SECTION */} {loadingRisk ? (

Loading AI risk...

- ) : aiRisk ? ( + ) : aiRisk?.riskLevel && aiRisk?.reasoning ? (
AI Risk Level: {aiRisk.riskLevel}
diff --git a/user_profile.db b/user_profile.db index 3589a1570da62c1626168f24d71cf6134518c2cb..abb6722d6b42149acb3a09d67d6e242d399f18f2 100644 GIT binary patch delta 4255 zcmcgwzi%8x6t?rr=0KcKkPtc|MndA3&&GG>6LwI9EEGgUqJSJk0TG(p9p6s8JF}UY zJ^LuwBz8kbD%ymO0;NC{`~g%{`~my{si^4r-t6vq&*zX7f$G+~J8#~+_kG{{*0=60 z-nzH=U2Fcq-B&(1{a|jz{BS$`C7i$a*NcmDow?`Ve(v|i)y416JUsQ|iSAtI?&iz+ z{x^$_)`k6rrE~kT*X=L&R=b@o5pOSFR7M6-B9vp-1P>x%tp8=&FsZCFS>SLH%8?8+ z9$UIUG&)Ny+s37f1WS!fxEZrFGpV+M>1}2yv^Wf%<5eR9Be;`VwVB~qNUOQ>Veq4a zAWQvY9y2|-Ap$2yqRm8@1=Vft9G8lcZL>fpsc^E0hw$6dDPir(7sXn3)rYu#MTf%h z+=)%gHZL)rIi2`$2%wx)nXt@h#={X;0k)&O1e2C=miVPkFeB{_=t2?U)^ckM%Mgd8e%8GB}YDA?=lvUDh!%TR|35^eU0w$2LFEJUjdgv8|%)Jla6 z$yKWYof%skpnNCCU;41+7u7J9HdLWW)^W!TwQ}prlfvOCtiGRx9k92YZQH^fnMip_>Dp zxnq%u4lJ>2#y@ z{_U5bUme0w7Z}>AG>L5mk=F4V0^OCx_a;`$ks0ed%bh9(ufJmjx`3A zTBn8PAiJDWsVPjQ&UjP7E8-0py3AngXK|La*)Wq~;m0Zu>W^9yr5?#kX~l`sS9sko zX-3LD%yKw_Cl>4fK&x|C8qp`R4wY8dEJF(E&+2|}?)ZIp0MwCY9{4#}rh-r*RdjE!boE&2bNttG*I zjM}fR-TCrFqxJ6X`K7b_?-gp_>3$623Mhral$l_#P z14j;5R^oqD-ofDMZ-lAZ;H=h>r@~!{$$>FNWOl$-(FIacrJd*8;jp8_=Jb6)E#T9M zRDeE~l%$c>sMOxh;ACiqB?<82Q6mR@iC_V~*67?cWyYY^kUlz$5+ zHSah7`1u9)ZS$*VPqyaE{{bwP*Yqj4P+h}Tohh%$Vx9Ka#qd5gd1t+Ia`H~6d!li& K^^CV<-R?h7U*E(4 delta 35 rcmZozz|pXPV}dl}@`*Cetjihnk{KIQwx%#Hc)ytCfxu>#hCloO;E4@6