feat: add toolip selection + auto-predict train sample

This commit is contained in:
Mohamed Marrouchi
2025-05-14 08:15:22 +01:00
parent 7dd3297fa3
commit 6de740f683
4 changed files with 214 additions and 96 deletions

View File

@@ -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 (
<div className="highlight">
<span>{e.start}</span>
<Tooltip
open={true}
placement="top"
title={e.entity}
arrow
componentsProps={{
tooltip: {
sx: {
color: "#FFF",
backgroundColor: e.style.backgroundColor,
},
},
arrow: {
sx: {
color: e.style.backgroundColor,
},
},
}}
>
<span style={e.style}>{e.value}</span>
</Tooltip>
<span>{e.end}</span>
</div>
);
};
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<SelectableProps> = ({
defaultValue,
entities = [],
keywordEntities = [],
patternEntities = [],
placeholder = "",
onChange,
onSelect,
@@ -76,20 +145,13 @@ const Selectable: FC<SelectableProps> = ({
const [text, setText] = useState(defaultValue || "");
const editableRef = useRef<HTMLDivElement>(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<SelectableProps> = ({
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<SelectableProps> = ({
onChange({ text: newText, entities: newEntities });
},
[text, onChange, entities],
[text, onChange, keywordEntities],
);
return (
<SelectableBox ref={selectableRef}>
{selectedEntities?.map((e, idx) => (
<div key={idx} className="highlight">
<span>{e.start}</span>
<span style={e.style}>{e.value}</span>
<span>{e.end}</span>
</div>
{selectedPatternEntities?.map((e, idx) => (
<SelectionEntityBackground
key={`${e.entity}_${e.value}_${idx}`}
selectionEntity={e}
/>
))}
{selectedKeywordEntities?.map((e, idx) => (
<SelectionEntityBackground
key={`${e.entity}_${e.value}_${idx}`}
selectionEntity={e}
/>
))}
<Input
ref={editableRef}

View File

@@ -30,15 +30,16 @@ import { ContentContainer, ContentItem } from "@/app-components/dialogs";
import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect";
import AutoCompleteSelect from "@/app-components/inputs/AutoCompleteSelect";
import Selectable from "@/app-components/inputs/Selectable";
import { useFind } from "@/hooks/crud/useFind";
import { useGetFromCache } from "@/hooks/crud/useGet";
import { useApiClient } from "@/hooks/useApiClient";
import { useNlp } from "@/hooks/useNlp";
import { useTranslate } from "@/hooks/useTranslate";
import { EntityType, Format } from "@/services/types";
import { ILanguage } from "@/types/language.types";
import { INlpEntity } from "@/types/nlp-entity.types";
import {
INlpDatasetKeywordEntity,
INlpDatasetPatternEntity,
INlpDatasetSample,
INlpDatasetTraitEntity,
INlpSampleFormAttributes,
@@ -56,39 +57,32 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
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<INlpSampleFormAttributes>({
@@ -97,6 +91,9 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
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<NlpDatasetSampleProps> = ({
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<NlpDatasetSampleProps> = ({
} | null>(null);
const onSubmitForm = (form: INlpSampleFormAttributes) => {
submitForm(form);
refetchEntities();
refetchAllEntities();
reset({
...defaultValues,
text: "",
@@ -203,7 +207,8 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
<ContentItem>
<Selectable
defaultValue={currentText}
entities={keywordEntities}
keywordEntities={keywordEntities}
patternEntities={patternEntities}
placeholder={t("placeholder.nlp_sample_text")}
onSelect={(selection, start, end) => {
setSelection({
@@ -223,11 +228,13 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
end,
})),
);
setPatternEntities([]);
}}
loading={isLoading}
/>
</ContentItem>
<Box display="flex" flexDirection="column">
{/* Language selection */}
<ContentItem
display="flex"
flexDirection="row"
@@ -261,6 +268,7 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
}}
/>
</ContentItem>
{/* Trait entities */}
{traitEntities.map((traitEntity, index) => (
<ContentItem
key={traitEntity.id}
@@ -275,13 +283,9 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
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<NlpDatasetSampleProps> = ({
</ContentItem>
))}
</Box>
{
/* Keyword entities */
}
<Box display="flex" flexDirection="column">
{keywordEntities.map((keywordEntity, index) => (
<ContentItem
@@ -335,24 +341,16 @@ const NlpDatasetSample: FC<NlpDatasetSampleProps> = ({
control={control}
render={({ field }) => {
const { onChange: _, ...rest } = field;
const options = [...allKeywordEntities.values()];
return (
<AutoCompleteEntitySelect<INlpEntity, "name", false>
<AutoCompleteSelect<INlpEntity, "name", false>
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<NlpDatasetSampleProps> = ({
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 (
<AutoCompleteSelect<

View File

@@ -0,0 +1,55 @@
/*
* 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.
* 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 { useMemo } from "react";
import { EntityType, Format } from "@/services/types";
import { INlpEntity, Lookup } from "@/types/nlp-entity.types";
import { useFind } from "./crud/useFind";
const buildNlpEntityMap = (entities: INlpEntity[], lookup: Lookup) => {
const intialMap = new Map<string, INlpEntity>();
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
}
};

View File

@@ -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<INlpSampleAttributes, "entities"> {
traitEntities: INlpDatasetTraitEntity[];