Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ENV LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL}
ENV OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID}
ENV OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET}
ENV ANALYTICS_API=${ANALYTICS_API}
ENV AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL}
RUN mkdir /app
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
- RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY-6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI}
- GA_ACCOUNT_ID=${GA_ACCOUNT_ID-UA-000000-01}
- ANALYTICS_API=${ANALYTICS_API-}
- AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL-}
- ERRBIT_URL
- ERRBIT_KEY
- HOTJAR_ID
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@
"xlsx": "^0.18.5"
},
"scripts": {
"start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env.API_URL=${API_URL} --env.NODE_ENV=${NODE_ENV} --env.RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env.GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env.HOTJAR_ID=${HOTJAR_ID} --env.ERRBIT_URL=${ERRBIT_URL} --env.ERRBIT_KEY=${ERRBIT_KEY} --env.LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env.OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env.OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET} --env.ANALYTICS_API=${ANALYTICS_API} --mode ${NODE_ENV} --hot",
"build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --host 0.0.0.0 --port 443 --env.API_URL=${API_URL} --env.NODE_ENV=${NODE_ENV} --env.RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env.GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env.HOTJAR_ID=${HOTJAR_ID} --env.ERRBIT_URL=${ERRBIT_URL} --env.ERRBIT_KEY=${ERRBIT_KEY} --env.LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env.OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env.OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET} --env.ANALYTICS_API=${ANALYTICS_API} --mode ${NODE_ENV}",
"start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env.API_URL=${API_URL} --env.NODE_ENV=${NODE_ENV} --env.RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env.GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env.HOTJAR_ID=${HOTJAR_ID} --env.ERRBIT_URL=${ERRBIT_URL} --env.ERRBIT_KEY=${ERRBIT_KEY} --env.LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env.OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env.OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET} --env.ANALYTICS_API=${ANALYTICS_API} --env.AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV} --hot",
"build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --host 0.0.0.0 --port 443 --env.API_URL=${API_URL} --env.NODE_ENV=${NODE_ENV} --env.RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env.GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env.HOTJAR_ID=${HOTJAR_ID} --env.ERRBIT_URL=${ERRBIT_URL} --env.ERRBIT_KEY=${ERRBIT_KEY} --env.LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env.OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env.OIDC_RP_CLIENT_SECRET=${OIDC_RP_CLIENT_SECRET} --env.ANALYTICS_API=${ANALYTICS_API} --env.AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --mode ${NODE_ENV}",
"eslint": "./node_modules/.bin/eslint ./src"
},
"devDependencies": {
Expand Down
199 changes: 195 additions & 4 deletions src/components/concepts/ConceptForm.jsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,42 @@
/*eslint no-process-env: 0*/
/*global process*/
import React from 'react';
import { compact, map, isEmpty } from 'lodash';
import { compact, map, isEmpty, flatten, values, keys, get, isArray, cloneDeep, isEqual, omit } from 'lodash';
import TextField from '@mui/material/TextField'
import { flatten, values, keys, get, isArray } from 'lodash'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import CircularProgress from '@mui/material/CircularProgress'
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
Comment thread
snyaggarwal marked this conversation as resolved.
import CloseIconButton from '../common/CloseIconButton';
import APIService from '../../services/APIService'
import FormComponent, { CardSection } from '../common/FormComponent'
import { sortValuesBySourceSummary } from '../repos/utils';
import {
fetchDatatypes, fetchNameTypes, fetchDescriptionTypes, fetchConceptClasses, fetchLocales
} from './utils';
import { toParentURI } from '../../common/utils'
import { toParentURI, isSuperuser, hasAuthGroup, getCurrentUser } from '../../common/utils'
import { OperationsContext } from '../app/LayoutContext';
import Button from '../common/Button'
import AutocompleteGroupByRepoSummary from '../common/AutocompleteGroupByRepoSummary'
import LocaleForm from './LocaleForm'
import Breadcrumbs from '../common/Breadcrumbs'
import CustomAttributesForm from '../common/CustomAttributesForm'

const TOP_LEVEL_PROMPT_EXCLUSIONS = [
'uuid', 'type', 'url', 'version', 'version_url', 'versions_url', 'versioned_object_id', 'created_on',
'updated_on', 'created_by', 'updated_by', 'update_comment', 'comment', 'checksums', 'public_can_view',
'latest_source_version', 'owner_url', 'owner_type'
];

const NAME_PROMPT_EXCLUSIONS = ['uuid', 'checksum', 'type'];
const DESCRIPTION_PROMPT_EXCLUSIONS = ['uuid', 'checksum', 'type'];
const MAPPING_PROMPT_EXCLUSIONS = [
'uuid', 'checksums', 'type', 'url', 'version', 'version_url', 'versioned_object_id', 'versioned_object_url',
'created_on', 'updated_on', 'created_by', 'updated_by', 'update_comment', 'is_latest_version',
'version_created_on', 'version_updated_on', 'version_updated_by', 'public_can_view', 'latest_source_version',
'sort_weight', 'owner_type', 'from_source_owner_type', 'to_source_owner_type', 'from_source_version',
'to_source_version', 'from_concept_url', 'from_source_url', 'from_source_owner', 'to_source_url', 'to_source_owner'
];

class ConceptForm extends FormComponent {
static contextType = OperationsContext;
Expand All @@ -37,6 +57,7 @@ class ConceptForm extends FormComponent {
selected_datatype: null,
manualMnemonic: false,
manualExternalId: false,
generatingChangeComment: false,
fields: {
id: {...mandatoryFieldStruct},
concept_class: {...mandatoryFieldStruct},
Expand All @@ -54,6 +75,142 @@ class ConceptForm extends FormComponent {
}
}

/*eslint no-undef: 0*/
getAIAssistantURL = () => window.AI_ASSISTANT_API_URL || process.env.AI_ASSISTANT_API_URL
Comment on lines +78 to +79

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the bare /*eslint no-undef: 0*/ disables the rule for the rest of the file. Since process is already declared via /*global process*/ at the top, scoping it to this one line is tighter:

Suggested change
/*eslint no-undef: 0*/
getAIAssistantURL = () => window.AI_ASSISTANT_API_URL || process.env.AI_ASSISTANT_API_URL
// eslint-disable-next-line no-undef
getAIAssistantURL = () => window.AI_ASSISTANT_API_URL || process.env.AI_ASSISTANT_API_URL


sanitizeNameForPrompt = name => omit(name || {}, NAME_PROMPT_EXCLUSIONS)

sanitizeDescriptionForPrompt = description => omit(description || {}, DESCRIPTION_PROMPT_EXCLUSIONS)

sanitizeMappingForPrompt = mapping => omit(mapping || {}, MAPPING_PROMPT_EXCLUSIONS)

sanitizeConceptForPrompt = concept => {
const sanitized = omit(cloneDeep(concept || {}), TOP_LEVEL_PROMPT_EXCLUSIONS)

if (isArray(sanitized.names))
sanitized.names = sanitized.names.map(this.sanitizeNameForPrompt)

if (isArray(sanitized.descriptions))
sanitized.descriptions = sanitized.descriptions.map(this.sanitizeDescriptionForPrompt)

if (isArray(sanitized.mappings))
sanitized.mappings = sanitized.mappings.map(this.sanitizeMappingForPrompt)

return sanitized
}

normalizeNamesForComparison = names => (names || []).map(name => ({
locale: name?.locale || '',
name_type: name?.name_type || '',
locale_preferred: Boolean(name?.locale_preferred),
name: name?.name || '',
external_id: name?.external_id || '',
}))

normalizeDescriptionsForComparison = descriptions => (descriptions || []).map(description => ({
locale: description?.locale || '',
description_type: description?.description_type || '',
locale_preferred: Boolean(description?.locale_preferred),
description: description?.description || '',
external_id: description?.external_id || '',
}))

getComparableOriginalConcept = () => {
const concept = this.props.concept || {}

return {
id: concept.id || '',
concept_class: concept.concept_class || '',
datatype: concept.datatype || '',
external_id: concept.external_id || '',
extras: concept.extras || {},
parent_concept_urls: concept.parent_concept_urls || [],
names: this.normalizeNamesForComparison(concept.names),
descriptions: this.normalizeDescriptionsForComparison(concept.descriptions),
}
}

getComparableCurrentConcept = () => {
const valuesMap = this.getValues()

return {
id: valuesMap.id || '',
concept_class: valuesMap.concept_class || '',
datatype: valuesMap.datatype || '',
external_id: valuesMap.external_id || '',
extras: valuesMap.extras || {},
parent_concept_urls: valuesMap.parent_concept_urls || [],
names: this.normalizeNamesForComparison(valuesMap.names),
descriptions: this.normalizeDescriptionsForComparison(valuesMap.descriptions),
}
}

hasConceptChanges = () => !isEqual(this.getComparableOriginalConcept(), this.getComparableCurrentConcept())

getPromptConceptA = () => this.sanitizeConceptForPrompt(this.props.concept)

getPromptConceptB = () => {
const baseConcept = this.sanitizeConceptForPrompt(this.props.concept)
const formValues = this.getValues()
delete formValues.comment

return this.sanitizeConceptForPrompt({
...baseConcept,
...formValues,
names: formValues.names || [],
descriptions: formValues.descriptions || [],
extras: formValues.extras || {},
parent_concept_urls: formValues.parent_concept_urls || [],
mappings: baseConcept.mappings,
})
}

generateChangeComment = async () => {
const { setAlert } = this.context;
const { t } = this.props
const aiAssistantURL = this.getAIAssistantURL()

if (!aiAssistantURL) {
setAlert({duration: 8000, message: t('concept.ai_assistant_not_configured'), severity: 'error'})
return
}

if (!this.hasConceptChanges())
return

this.setState({generatingChangeComment: true})

try {
const response = await APIService.new().request(
'POST',
{
variables: {
concept_a: this.getPromptConceptA(),
concept_b: this.getPromptConceptB(),
}
},
null,
{ url: `${aiAssistantURL}/prompts/concept-generate-change-comment/$invoke/` }
)

const output = (get(response, 'data.output') || '').trim()

if (!output)
throw new Error('No generated comment was returned.')

this.setFieldValue('comment', output)
} catch (error) {
const status = error?.response?.status
const message = status === 429 ?
t('concept.try_again_in_a_moment') :
(error?.response?.data?.detail || error?.response?.data?.error || error?.message || t('common.generic_error'))

setAlert({duration: 10000, message, severity: 'error'})
} finally {
this.setState({generatingChangeComment: false})
}
}

getNameStruct = (preferred=false) => {
const mandatoryFieldStruct = this.getMandatoryFieldStruct()
const fieldStruct = this.getFieldStruct()
Expand Down Expand Up @@ -191,10 +348,14 @@ class ConceptForm extends FormComponent {
handleSubmit = event => {
event.preventDefault()
event.stopPropagation()
const { edit } = this.props
const { fields } = this.state
const isValid = this.setAllFieldsErrors()
if(isValid) {
const { setAlert } = this.context;
const payload = this.getValues()
if(edit)
payload.update_comment = fields.comment.value
Comment on lines +357 to +358

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getValues() already puts comment in the payload, so the PUT now carries both comment and update_comment. Worth dropping the stray key. (This line is also effectively the fix that makes update comments persist at all — previously the value was only sent as comment, which the update endpoint ignores.)

Suggested change
if(edit)
payload.update_comment = fields.comment.value
if(edit) {
payload.update_comment = fields.comment.value
delete payload.comment
}

let service = APIService.new().overrideURL(this.props.source.url).appendToUrl('concepts/')
service = this.props.edit ? service.appendToUrl(this.state.fields.id.value + '/').put(payload) : service.post(payload)
service.then(response => {
Expand All @@ -219,7 +380,15 @@ class ConceptForm extends FormComponent {

render() {
const { t, edit, repoSummary, repo, concept, onClose } = this.props
const { conceptClasses, datatypes, locales, nameTypes, descriptionTypes, fields } = this.state
const { conceptClasses, datatypes, locales, nameTypes, descriptionTypes, fields, generatingChangeComment } = this.state
const aiAssistantConfigured = Boolean(this.getAIAssistantURL())
const canSeeGenerateComment = edit && (isSuperuser() || hasAuthGroup(getCurrentUser(), 'core_user'))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gates the button to core_users in addition to superusers, but per the ticket the AI Assistant $invoke endpoint still requires superuser — it notes this "must be relaxed to allow staff/authenticated users before this feature ships (separate ticket needed)." @snyaggarwal could you take the backend permissions work so core_users can actually invoke this? Otherwise non-superuser core_users will see the button and hit a 403.

const hasConceptChanges = canSeeGenerateComment && this.hasConceptChanges()
const canGenerateComment = canSeeGenerateComment && aiAssistantConfigured && hasConceptChanges && !generatingChangeComment
const generateCommentTooltip = !aiAssistantConfigured ?
t('concept.ai_assistant_not_configured') :
(!hasConceptChanges ? t('concept.make_change_before_generating') : t('common.generate_with_ai'))

return (
<div className='col-xs-12' style={{padding: '8px 16px 12px 16px', height: '100%', overflow: 'auto'}}>
<div className='col-xs-12 padding-0' style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px'}}>
Expand Down Expand Up @@ -353,6 +522,28 @@ class ConceptForm extends FormComponent {
edit &&
<CardSection title={t('common.update_comment')}>
<div className='col-xs-12 padding-0' style={{marginTop: '24px'}}>
{
canSeeGenerateComment &&
<div style={{display: 'flex', justifyContent: 'flex-end', marginBottom: '8px'}}>
<Tooltip arrow title={generateCommentTooltip}>
<span>
<IconButton
color='secondary'
size='small'
onClick={this.generateChangeComment}
disabled={!canGenerateComment}
aria-label={t('concept.generate_comment_aria')}
>
{
generatingChangeComment ?
<CircularProgress size={18} color='inherit' /> :
<AutoAwesomeIcon fontSize='small' />
}
</IconButton>
</span>
</Tooltip>
</div>
}
<TextField
id="comment"
label={t('common.comment')}
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"default": "Default",
"processing": "Processing",
"load_more": "Load more",
"generate_with_ai": "Generate with AI",
"release": "Release",
"unrelease": "Un-Release",
"reason": "Reason",
Expand Down Expand Up @@ -233,7 +234,11 @@
"header": "Descriptions"
}
},
"edit_concept": "Edit Concept"
"edit_concept": "Edit Concept",
"ai_assistant_not_configured": "AI assistant is not configured for this environment.",
"try_again_in_a_moment": "Try again in a moment.",
"make_change_before_generating": "Make a change to the concept before generating a comment.",
"generate_comment_aria": "Generate comment with AI"
},
"mapping": {
"mapping": "Mapping",
Expand Down
9 changes: 7 additions & 2 deletions src/i18n/locales/es/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"none": "Ninguno",
"results": "Resultados",
"custom": "Personalizado",
"load_more": "Cargar más"
"load_more": "Cargar más",
"generate_with_ai": "Generar con IA"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common.generic_error is the catch-all fallback in generateChangeComment but only exists in en. Easy to round out while you're here:

Suggested change
"generate_with_ai": "Generar con IA"
"generate_with_ai": "Generar con IA",
"generic_error": "Algo salió mal."

},
"errors": {
"404": "Lo siento, no se pudo encontrar tu página."
Expand Down Expand Up @@ -71,7 +72,11 @@
"name_and_synonyms": "Nombre y sinónimos",
"descriptions": "Descripciones",
"copied_name": "Nombre del concepto y URL copiados al portapapeles",
"copied_description": "Descripción del concepto y URL copiados al portapapeles"
"copied_description": "Descripción del concepto y URL copiados al portapapeles",
"ai_assistant_not_configured": "El asistente de IA no está configurado para este entorno.",
"try_again_in_a_moment": "Vuelve a intentarlo en un momento.",
"make_change_before_generating": "Haz un cambio en el concepto antes de generar un comentario.",
"generate_comment_aria": "Generar comentario con IA"
},
"mapping": {
"mappings": "Mapeos"
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/locales/zh/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"custom": "自定义",
"none": "无",
"load_more": "加载更多",
"generate_with_ai": "使用 AI 生成",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here — add the generic_error fallback used by generateChangeComment:

Suggested change
"generate_with_ai": "使用 AI 生成",
"generate_with_ai": "使用 AI 生成",
"generic_error": "出了些问题。",

"something_went_wrong": "出了些问题",
"no_results": "无结果"
},
Expand Down Expand Up @@ -171,7 +172,11 @@
"header": "描述"
}
},
"edit_concept": "编辑概念"
"edit_concept": "编辑概念",
"ai_assistant_not_configured": "此环境尚未配置 AI 助手。",
"try_again_in_a_moment": "请稍后再试。",
"make_change_before_generating": "请先更改概念,再生成评论。",
"generate_comment_aria": "使用 AI 生成评论"
},
"mapping": {
"mappings": "映射关系",
Expand Down
3 changes: 3 additions & 0 deletions start-prod.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ fi
if [[ ! -z "${ANALYTICS_API}" ]]; then
echo "var ANALYTICS_API = \"${ANALYTICS_API}\";" >> ${ENV_FILE}
fi
if [[ ! -z "${AI_ASSISTANT_API_URL}" ]]; then
echo "var AI_ASSISTANT_API_URL = \"${AI_ASSISTANT_API_URL}\";" >> ${ENV_FILE}
fi

echo "Adjusting nginx configuration"
envsubst '$WEB_PORT' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
Expand Down
1 change: 1 addition & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ module.exports = (env) => {
'process.env.OIDC_RP_CLIENT_ID': JSON.stringify(env.OIDC_RP_CLIENT_ID),
'process.env.OIDC_RP_CLIENT_SECRET': JSON.stringify(env.OIDC_RP_CLIENT_SECRET),
'process.env.ANALYTICS_API': JSON.stringify(env.ANALYTICS_API) || '',
'process.env.AI_ASSISTANT_API_URL': JSON.stringify(env.AI_ASSISTANT_API_URL),
}),
new IgnorePlugin({ resourceRegExp: /moment\/locale\// })
],
Expand Down