Skip to content
Merged
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
201 changes: 197 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-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,16 @@ 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
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 +382,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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, raising separate PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 +524,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
10 changes: 8 additions & 2 deletions src/i18n/locales/es/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
"none": "Ninguno",
"results": "Resultados",
"custom": "Personalizado",
"load_more": "Cargar más"
"load_more": "Cargar más",
"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 +73,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
8 changes: 7 additions & 1 deletion src/i18n/locales/zh/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@
"custom": "自定义",
"none": "无",
"load_more": "加载更多",
"generate_with_ai": "使用 AI 生成",
Comment thread
snyaggarwal marked this conversation as resolved.
"generic_error": "出了些问题。",
"something_went_wrong": "出了些问题",
"no_results": "无结果"
},
Expand Down Expand Up @@ -171,7 +173,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