mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
feat: add toolip selection + auto-predict train sample
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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<
|
||||
|
||||
55
frontend/src/hooks/useNlp.tsx
Normal file
55
frontend/src/hooks/useNlp.tsx
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user