Skip to content

Commit fd0ecba

Browse files
authored
Merge pull request #1725 from topcoder-platform/PM-3786_ai-assisted-skills
PM-3786 ai assisted skills suggestions
2 parents 3a051ff + 4638c97 commit fd0ecba

7 files changed

Lines changed: 324 additions & 40 deletions

File tree

config/constants/development.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ module.exports = {
2020
ENGAGEMENTS_ROOT_API_URL: `${DEV_API_HOSTNAME}/v6/engagements`,
2121
APPLICATIONS_API_URL: `${DEV_API_HOSTNAME}/v6/engagements/applications`,
2222
TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`,
23+
TC_AI_API_BASE_URL: process.env.TC_AI_API_BASE_URL || `${API_V6}/ai`,
24+
TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID: process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow',
2325
CHALLENGE_DEFAULT_REVIEWERS_URL: `${DEV_API_HOSTNAME}/v6/challenge/default-reviewers`,
2426
CHALLENGE_API_VERSION: '1.1.0',
2527
CHALLENGE_TIMELINE_TEMPLATES_URL: `${DEV_API_HOSTNAME}/v6/timeline-templates`,

config/constants/production.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ module.exports = {
1919
ENGAGEMENTS_ROOT_API_URL: `${PROD_API_HOSTNAME}/v6/engagements`,
2020
APPLICATIONS_API_URL: `${PROD_API_HOSTNAME}/v6/engagements/applications`,
2121
TC_FINANCE_API_URL: process.env.TC_FINANCE_API_URL || `${API_V6}/finance`,
22+
TC_AI_API_BASE_URL: process.env.TC_AI_API_BASE_URL || `${API_V6}/ai`,
23+
TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID: process.env.TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID || 'skillExtractionWorkflow',
2224
CHALLENGE_DEFAULT_REVIEWERS_URL: `${PROD_API_HOSTNAME}/v6/challenge/default-reviewers`,
2325
CHALLENGE_API_VERSION: '1.1.0',
2426
CHALLENGE_TIMELINE_TEMPLATES_URL: `${PROD_API_HOSTNAME}/v6/timeline-templates`,

docs/dev.env

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/components/ChallengeEditor/SkillsField/index.js

Lines changed: 121 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1-
import React, { useMemo } from 'react'
1+
import React, { useMemo, useState } from 'react'
22
import PropTypes from 'prop-types'
33
import Select from '../../Select'
44
import { searchSkills } from '../../../services/skills'
55
import cn from 'classnames'
66
import styles from './styles.module.scss'
77
import { AUTOCOMPLETE_DEBOUNCE_TIME_MS, SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS } from '../../../config/constants'
88
import _ from 'lodash'
9+
import { extractSkillsFromText } from '../../../services/workflowAI'
10+
import { toastSuccess, toastFailure } from '../../../util/toaster'
11+
import { OutlineButton } from '../../Buttons'
12+
import Loader from '../../Loader'
913

1014
const fetchSkills = _.debounce((inputValue, callback) => {
1115
searchSkills(inputValue).then(
@@ -22,6 +26,8 @@ const fetchSkills = _.debounce((inputValue, callback) => {
2226
}, AUTOCOMPLETE_DEBOUNCE_TIME_MS)
2327

2428
const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
29+
const [isLoadingAI, setIsLoadingAI] = useState(false)
30+
2531
const selectedSkills = useMemo(() => (challenge.skills || []).map(skill => ({
2632
label: skill.name,
2733
value: skill.id
@@ -31,30 +37,86 @@ const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
3137
const normalizedBillingAccountId = _.isNil(billingAccountId) ? null : String(billingAccountId)
3238
const skillsRequired = normalizedBillingAccountId ? !SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS.includes(normalizedBillingAccountId) : true
3339
const showRequiredError = !readOnly && skillsRequired && challenge.submitTriggered && (!selectedSkills || !selectedSkills.length)
40+
41+
// Check if description exists to show AI button
42+
const hasDescription = challenge.description && challenge.description.trim().length > 0
43+
44+
const handleAISuggest = async () => {
45+
if (!hasDescription || isLoadingAI) {
46+
return
47+
}
48+
49+
setIsLoadingAI(true)
50+
try {
51+
const result = await extractSkillsFromText(challenge.description)
52+
const matches = result.matches || []
53+
54+
if (matches.length === 0) {
55+
toastFailure('No Skills Found', 'No matching standardized skills found based on the description.')
56+
} else {
57+
// Merge with existing skills, avoiding duplicates
58+
const existingSkillIds = new Set((challenge.skills || []).map(s => s.id))
59+
const newSkills = matches.filter(skill => !existingSkillIds.has(skill.id))
60+
61+
if (newSkills.length === 0) {
62+
toastSuccess('Skills Already Added', 'All suggested skills are already in your selection.')
63+
} else {
64+
const updatedSkills = [...(challenge.skills || []), ...newSkills]
65+
onUpdateSkills(updatedSkills)
66+
toastSuccess('Skills Added', `${newSkills.length} skill(s) were added from AI suggestions.`)
67+
}
68+
}
69+
} catch (error) {
70+
console.error('AI skill extraction error:', error)
71+
toastFailure('Error', 'Failed to extract skills. Please try again or add skills manually.')
72+
} finally {
73+
setIsLoadingAI(false)
74+
}
75+
}
3476

3577
if (embedded) {
3678
return (
3779
<div className={styles.embeddedWrapper}>
3880
<input type='hidden' />
39-
{readOnly ? (
40-
<div className={styles.embeddedReadOnly}>{existingSkills || '-'}</div>
41-
) : (
42-
<Select
43-
id='skill-select'
44-
isMulti
45-
simpleValue
46-
isAsync
47-
value={selectedSkills}
48-
onChange={(values) => {
49-
onUpdateSkills((values || []).map(value => ({
50-
name: value.label,
51-
id: value.value
52-
})))
53-
}}
54-
cacheOptions
55-
loadOptions={fetchSkills}
56-
/>
57-
)}
81+
<div className={styles.embeddedContent}>
82+
{isLoadingAI && (
83+
<div className={styles.loadingOverlay}>
84+
<Loader />
85+
<span className={styles.loadingText}>Generating skill suggestions...</span>
86+
</div>
87+
)}
88+
{readOnly ? (
89+
<div className={styles.embeddedReadOnly}>{existingSkills || '-'}</div>
90+
) : (
91+
<>
92+
<Select
93+
id='skill-select'
94+
isMulti
95+
simpleValue
96+
isAsync
97+
value={selectedSkills}
98+
onChange={(values) => {
99+
onUpdateSkills((values || []).map(value => ({
100+
name: value.label,
101+
id: value.value
102+
})))
103+
}}
104+
cacheOptions
105+
loadOptions={fetchSkills}
106+
isDisabled={isLoadingAI}
107+
/>
108+
{hasDescription && (
109+
<OutlineButton
110+
type='info'
111+
text='AI Suggest'
112+
onClick={handleAISuggest}
113+
disabled={isLoadingAI}
114+
className={styles.aiSuggestButton}
115+
/>
116+
)}
117+
</>
118+
)}
119+
</div>
58120
{showRequiredError && (
59121
<div className={styles.embeddedError}>Select at least one skill</div>
60122
)}
@@ -70,25 +132,45 @@ const SkillsField = ({ readOnly, challenge, onUpdateSkills, embedded }) => {
70132
</div>
71133
<div className={cn(styles.field, styles.col2)}>
72134
<input type='hidden' />
73-
{readOnly ? (
74-
<span>{existingSkills}</span>
75-
) : (
76-
<Select
77-
id='skill-select'
78-
isMulti
79-
simpleValue
80-
isAsync
81-
value={selectedSkills}
82-
onChange={(values) => {
83-
onUpdateSkills((values || []).map(value => ({
84-
name: value.label,
85-
id: value.value
86-
})))
87-
}}
88-
cacheOptions
89-
loadOptions={fetchSkills}
90-
/>
91-
)}
135+
<div className={styles.skillsFieldWrapper}>
136+
{isLoadingAI && (
137+
<div className={styles.loadingOverlay}>
138+
<Loader />
139+
<span className={styles.loadingText}>Generating skill suggestions...</span>
140+
</div>
141+
)}
142+
{readOnly ? (
143+
<span>{existingSkills}</span>
144+
) : (
145+
<>
146+
<Select
147+
id='skill-select'
148+
isMulti
149+
simpleValue
150+
isAsync
151+
value={selectedSkills}
152+
onChange={(values) => {
153+
onUpdateSkills((values || []).map(value => ({
154+
name: value.label,
155+
id: value.value
156+
})))
157+
}}
158+
cacheOptions
159+
loadOptions={fetchSkills}
160+
isDisabled={isLoadingAI}
161+
/>
162+
{hasDescription && (
163+
<OutlineButton
164+
type='info'
165+
text='AI Suggest'
166+
onClick={handleAISuggest}
167+
disabled={isLoadingAI}
168+
className={styles.aiSuggestButton}
169+
/>
170+
)}
171+
</>
172+
)}
173+
</div>
92174
</div>
93175
</div>
94176

src/components/ChallengeEditor/SkillsField/styles.module.scss

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,54 @@
5151
}
5252
}
5353

54+
.skillsFieldWrapper {
55+
position: relative;
56+
display: flex;
57+
flex-direction: column;
58+
gap: 12px;
59+
width: 100%;
60+
}
61+
62+
.aiSuggestButton {
63+
align-self: flex-start;
64+
min-width: 120px;
65+
}
66+
67+
.loadingOverlay {
68+
position: absolute;
69+
top: 0;
70+
left: 0;
71+
right: 0;
72+
bottom: 0;
73+
background: rgba(255, 255, 255, 0.9);
74+
display: flex;
75+
flex-direction: column;
76+
align-items: center;
77+
justify-content: center;
78+
z-index: 10;
79+
border-radius: 4px;
80+
gap: 12px;
81+
}
82+
83+
.loadingText {
84+
font-size: 14px;
85+
color: $tc-gray-80;
86+
font-weight: 500;
87+
}
88+
5489
.embeddedWrapper {
5590
display: flex;
5691
flex-direction: column;
5792
width: 100%;
5893
}
5994

95+
.embeddedContent {
96+
position: relative;
97+
display: flex;
98+
flex-direction: column;
99+
gap: 12px;
100+
}
101+
60102
.embeddedError {
61103
color: $tc-red;
62104
font-size: 12px;

src/config/constants.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,21 @@ export const {
4141
SALESFORCE_BILLING_ACCOUNT_LINK,
4242
PROFILE_URL,
4343
TC_FINANCE_API_URL,
44+
TC_AI_API_BASE_URL,
45+
TC_AI_SKILLS_EXTRACTION_WORKFLOW_ID,
4446
ENGAGEMENTS_APP_URL
4547
} = process.env
4648

4749
export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS === 'string' ? process.env.CREATE_FORUM_TYPE_IDS.split(',') : process.env.CREATE_FORUM_TYPE_IDS
4850
export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL
4951
export const SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS = ['80000062']
5052

53+
/**
54+
* AI Workflow Service config
55+
*/
56+
export const AI_WORKFLOW_POLL_INTERVAL = 1000 // 1 second in milliseconds
57+
export const AI_WORKFLOW_POLL_TIMEOUT = 5 * 60000 // 5 * 60 seconds in milliseconds
58+
5159
/**
5260
* Filepicker config
5361
*/

0 commit comments

Comments
 (0)