diff --git a/frontend/src/app-components/inputs/Selectable.tsx b/frontend/src/app-components/inputs/Selectable.tsx index 70fac37b..09ab6197 100644 --- a/frontend/src/app-components/inputs/Selectable.tsx +++ b/frontend/src/app-components/inputs/Selectable.tsx @@ -6,11 +6,22 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { Box, CircularProgress, Input, styled } from "@mui/material"; +import { Box, CircularProgress, Input, styled, Tooltip } from "@mui/material"; import randomSeed from "random-seed"; -import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + CSSProperties, + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; -import { INlpDatasetKeywordEntity } from "../../types/nlp-sample.types"; +import { + INlpDatasetKeywordEntity, + INlpDatasetPatternEntity, +} from "../../types/nlp-sample.types"; const SelectableBox = styled(Box)({ position: "relative", @@ -40,22 +51,62 @@ const COLORS = [ { name: "orange", bg: "#E6A23C" }, ]; const UNKNOWN_COLOR = { name: "grey", bg: "#aaaaaa" }; -const TODAY = new Date().toDateString(); -const getColor = (no: number) => { - const rand = randomSeed.create(TODAY); +const NOW = (+new Date()).toString(); +const getColor = (no: number, seedPrefix: string = "") => { + const rand = randomSeed.create(seedPrefix + NOW); const startIndex = rand(COLORS.length); const color = no < 0 ? UNKNOWN_COLOR : COLORS[(startIndex + no) % COLORS.length]; return { backgroundColor: color.bg, - opacity: 0.3, + opacity: 0.2, }; }; +interface INlpSelectionEntity { + start: string; + entity: string; + value: string; + end: string; + style: CSSProperties; +} +const SelectionEntityBackground: React.FC<{ + selectionEntity: INlpSelectionEntity; +}> = ({ selectionEntity: e }) => { + return ( +
+ {e.start} + + {e.value} + + {e.end} +
+ ); +}; + type SelectableProps = { defaultValue?: string; - entities?: INlpDatasetKeywordEntity[]; + keywordEntities?: INlpDatasetKeywordEntity[]; + patternEntities?: INlpDatasetPatternEntity[]; placeholder?: string; onSelect: (str: string, start: number, end: number) => void; onChange: (sample: { @@ -65,9 +116,27 @@ type SelectableProps = { loading?: boolean; }; +const buildSelectionEntities = ( + text: string, + entities: INlpDatasetKeywordEntity[] | INlpDatasetPatternEntity[], +): INlpSelectionEntity[] => { + return entities?.map((e, index) => { + const start = e.start ? e.start : text.indexOf(e.value); + const end = e.end ? e.end : start + e.value.length; + + return { + start: text.substring(0, start), + entity: e.entity, + value: text.substring(start, end), + end: text.substring(end), + style: getColor(e.entity ? index : -1, e.entity), + }; + }); +}; const Selectable: FC = ({ defaultValue, - entities = [], + keywordEntities = [], + patternEntities = [], placeholder = "", onChange, onSelect, @@ -76,20 +145,13 @@ const Selectable: FC = ({ const [text, setText] = useState(defaultValue || ""); const editableRef = useRef(null); const selectableRef = useRef(null); - const selectedEntities = useMemo( - () => - entities?.map((e, index) => { - const start = e.start ? e.start : text.indexOf(e.value); - const end = e.end ? e.end : start + e.value.length; - - return { - start: text.substring(0, start), - value: text.substring(start, end), - end: text.substring(end), - style: getColor(e.entity ? index : -1), - }; - }), - [entities, text], + const selectedKeywordEntities = useMemo( + () => buildSelectionEntities(text, keywordEntities), + [keywordEntities, text], + ); + const selectedPatternEntities = useMemo( + () => buildSelectionEntities(text, patternEntities), + [patternEntities, text], ); useEffect(() => { @@ -143,7 +205,7 @@ const Selectable: FC = ({ const handleTextChange = useCallback( (newText: string) => { const oldText = text; - const oldEntities = [...entities]; + const oldEntities = [...keywordEntities]; const newEntities: INlpDatasetKeywordEntity[] = []; const findCharDiff = (oldStr: string, newStr: string): number => { const minLength = Math.min(oldStr.length, newStr.length); @@ -187,17 +249,22 @@ const Selectable: FC = ({ onChange({ text: newText, entities: newEntities }); }, - [text, onChange, entities], + [text, onChange, keywordEntities], ); return ( - {selectedEntities?.map((e, idx) => ( -
- {e.start} - {e.value} - {e.end} -
+ {selectedPatternEntities?.map((e, idx) => ( + + ))} + {selectedKeywordEntities?.map((e, idx) => ( + ))} = ({ submitForm, }) => { const { t } = useTranslate(); - const { data: entities, refetch: refetchEntities } = useFind( - { - entity: EntityType.NLP_ENTITY, - format: Format.FULL, - }, - { - hasCount: false, - }, - ); + const { + allTraitEntities, + allKeywordEntities, + allPatternEntities, + refetchAllEntities, + } = useNlp(); const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE); - // eslint-disable-next-line react-hooks/exhaustive-deps const defaultValues: INlpSampleFormAttributes = useMemo( () => ({ type: sample?.type || NlpSampleType.train, text: sample?.text || "", language: sample?.language || null, - traitEntities: (entities || []) - .filter(({ lookups }) => { - return lookups.includes("trait"); - }) - .map((e) => { - return { - entity: e.name, - value: sample - ? sample.entities.find(({ entity }) => entity === e.name)?.value - : "", - } as INlpDatasetTraitEntity; - }), - keywordEntities: (sample?.entities || []).filter( - (e) => "start" in e && typeof e.start === "number", + traitEntities: [...allTraitEntities.values()].map((e) => { + return { + entity: e.name, + value: + (sample?.entities || []).find((se) => se.entity === e.name) + ?.value || "", + }; + }) as INlpDatasetTraitEntity[], + keywordEntities: (sample?.entities || []).filter((e) => + allKeywordEntities.has(e.entity), ) as INlpDatasetKeywordEntity[], }), - [sample, entities], + // eslint-disable-next-line react-hooks/exhaustive-deps + [allKeywordEntities, allTraitEntities, JSON.stringify(sample)], ); const { handleSubmit, control, register, reset, setValue, watch } = useForm({ @@ -97,6 +91,9 @@ const NlpDatasetSample: FC = ({ const currentText = watch("text"); const currentType = watch("type"); const { apiClient } = useApiClient(); + const [patternEntities, setPatternEntities] = useState< + INlpDatasetPatternEntity[] + >([]); const { fields: traitEntities, update: updateTraitEntity } = useFieldArray({ control, name: "traitEntities", @@ -122,22 +119,29 @@ const NlpDatasetSample: FC = ({ queryFn: async () => { return await apiClient.predictNlp(currentText); }, - onSuccess: (result) => { - const traitEntities: INlpDatasetTraitEntity[] = result.entities.filter( - (e) => !("start" in e && "end" in e) && e.entity !== "language", - ); - const keywordEntities = result.entities.filter( - (e) => "start" in e && "end" in e, + onSuccess: (prediction) => { + const predictedTraitEntities: INlpDatasetTraitEntity[] = + prediction.entities.filter((e) => allTraitEntities.has(e.entity)); + const predictedKeywordEntities = prediction.entities.filter((e) => + allKeywordEntities.has(e.entity), ) as INlpDatasetKeywordEntity[]; - const language = result.entities.find( + const predictedPatternEntities = prediction.entities.filter((e) => + allPatternEntities.has(e.entity), + ) as INlpDatasetKeywordEntity[]; + const language = prediction.entities.find( ({ entity }) => entity === "language", ); setValue("language", language?.value || ""); - setValue("traitEntities", traitEntities); - setValue("keywordEntities", keywordEntities); + setValue("traitEntities", predictedTraitEntities); + setValue("keywordEntities", predictedKeywordEntities); + setPatternEntities(predictedPatternEntities); }, - enabled: !sample && !!currentText, + enabled: + // Inbox sample update + sample?.type === "inbox" || + // New sample + (!sample && !!currentText), }); const findInsertIndex = (newItem: INlpDatasetKeywordEntity): number => { const index = keywordEntities.findIndex( @@ -153,7 +157,7 @@ const NlpDatasetSample: FC = ({ } | null>(null); const onSubmitForm = (form: INlpSampleFormAttributes) => { submitForm(form); - refetchEntities(); + refetchAllEntities(); reset({ ...defaultValues, text: "", @@ -203,7 +207,8 @@ const NlpDatasetSample: FC = ({ { setSelection({ @@ -223,11 +228,13 @@ const NlpDatasetSample: FC = ({ end, })), ); + setPatternEntities([]); }} loading={isLoading} /> + {/* Language selection */} = ({ }} /> + {/* Trait entities */} {traitEntities.map((traitEntity, index) => ( = ({ control={control} render={({ field }) => { const { onChange: _, value, ...rest } = field; - const entity = entities?.find( - ({ name }) => name === traitEntity.entity, - ); - const options = - entity?.values.map( - (v) => getNlpValueFromCache(v) as INlpValue, - ) || []; + const options = ( + allTraitEntities.get(traitEntity.entity)?.values || [] + ).map((v) => getNlpValueFromCache(v)!); return ( <> @@ -318,7 +322,9 @@ const NlpDatasetSample: FC = ({ ))} - + { + /* Keyword entities */ + } {keywordEntities.map((keywordEntity, index) => ( = ({ control={control} render={({ field }) => { const { onChange: _, ...rest } = field; + const options = [...allKeywordEntities.values()]; return ( - + fullWidth={true} - searchFields={["name"]} - entity={EntityType.NLP_ENTITY} - format={Format.FULL} + options={options} idKey="name" labelKey="name" label={t("label.nlp_entity")} multiple={false} - preprocess={(options) => { - return options.filter( - ({ lookups }) => - lookups.includes("keywords") || - lookups.includes("pattern"), - ); - }} onChange={(_e, selected, ..._) => { updateKeywordEntity(index, { ...keywordEntities[index], @@ -369,13 +367,9 @@ const NlpDatasetSample: FC = ({ control={control} render={({ field }) => { const { onChange: _, value, ...rest } = field; - const entity = entities?.find( - ({ name }) => name === keywordEntity.entity, - ); - const options = - entity?.values.map( - (v) => getNlpValueFromCache(v) as INlpValue, - ) || []; + const options = ( + allKeywordEntities.get(keywordEntity.entity)?.values || [] + ).map((v) => getNlpValueFromCache(v)!); return ( { + const intialMap = new Map(); + + return entities + .filter(({ lookups }) => { + return lookups.includes(lookup); + }).reduce((acc, curr) => { + acc.set(curr.name, curr); + + return acc; + }, intialMap) +} + +export const useNlp = () => { + const { data: allEntities, refetch: refetchAllEntities } = useFind( + { + entity: EntityType.NLP_ENTITY, + format: Format.FULL, + }, + { + hasCount: false, + }, + ); + const allTraitEntities = useMemo(() => { + return buildNlpEntityMap((allEntities || []), 'trait') + }, [allEntities]); + const allKeywordEntities = useMemo(() => { + return buildNlpEntityMap((allEntities || []), 'keywords') + }, [allEntities]); + const allPatternEntities = useMemo(() => { + return buildNlpEntityMap((allEntities || []), 'pattern') + }, [allEntities]); + + return { + allTraitEntities, + allKeywordEntities, + allPatternEntities, + refetchAllEntities + } +}; diff --git a/frontend/src/types/nlp-sample.types.ts b/frontend/src/types/nlp-sample.types.ts index 8069b95a..1884ce85 100644 --- a/frontend/src/types/nlp-sample.types.ts +++ b/frontend/src/types/nlp-sample.types.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -52,6 +52,8 @@ export interface INlpDatasetKeywordEntity extends INlpDatasetTraitEntity { end: number; } +export interface INlpDatasetPatternEntity extends INlpDatasetKeywordEntity {} + export interface INlpSampleFormAttributes extends Omit { traitEntities: INlpDatasetTraitEntity[];