From ab4dce3f600ca2c8df2c3901f2dc7eff2568e86b Mon Sep 17 00:00:00 2001 From: Adrian Cowan Date: Sun, 29 Jan 2023 03:07:43 +1100 Subject: [PATCH] website: Support new widget types for labelling (#966) * website: Support new widget types for labelling Adds proper support for yes/no spam style questions as well as a simple interface for flag style labels. Also cleaned up the Task component to fix some rerender issues. * website: Fix some UI text, adjust yes/no button alignment * website: Remove left over console.log Co-authored-by: notmd <33456881+notmd@users.noreply.github.com> --------- Co-authored-by: notmd <33456881+notmd@users.noreply.github.com> --- .../e2e/tasks/label_assistant_reply.cy.ts | 4 + .../e2e/tasks/label_initial_prompt.cy.ts | 4 + .../e2e/tasks/label_prompter_reply.cy.ts | 4 + website/cypress/e2e/tasks/random.cy.ts | 13 +- website/public/locales/en/common.json | 4 +- website/public/locales/en/labelling.json | 16 ++ website/src/components/FlaggableElement.tsx | 116 ---------- website/src/components/Messages.tsx | 20 +- .../components/Messages/LabelFlagGroup.tsx | 32 +++ .../components/Messages/LabelInputGroup.tsx | 84 +++++++ .../src/components/Messages/LabelPopup.tsx | 34 +-- .../components/Messages/LabelYesNoGroup.tsx | 89 ++++++++ .../components/Messages/MessageTableEntry.tsx | 2 +- ...belInputGroup.tsx => LabelLikertGroup.tsx} | 4 +- .../src/components/Survey/TaskControls.tsx | 46 ++-- website/src/components/Tasks/CreateTask.tsx | 6 +- website/src/components/Tasks/EvaluateTask.tsx | 18 +- .../components/Tasks/LabelTask/LabelTask.tsx | 109 +++++---- website/src/components/Tasks/Task/Task.tsx | 216 +++++++++++------- website/src/types/Tasks.ts | 34 +-- website/types/i18next.d.ts | 2 + 21 files changed, 504 insertions(+), 353 deletions(-) create mode 100644 website/public/locales/en/labelling.json delete mode 100644 website/src/components/FlaggableElement.tsx create mode 100644 website/src/components/Messages/LabelFlagGroup.tsx create mode 100644 website/src/components/Messages/LabelInputGroup.tsx create mode 100644 website/src/components/Messages/LabelYesNoGroup.tsx rename website/src/components/Survey/{LabelInputGroup.tsx => LabelLikertGroup.tsx} (98%) diff --git a/website/cypress/e2e/tasks/label_assistant_reply.cy.ts b/website/cypress/e2e/tasks/label_assistant_reply.cy.ts index 422db37c..18ab807f 100644 --- a/website/cypress/e2e/tasks/label_assistant_reply.cy.ts +++ b/website/cypress/e2e/tasks/label_assistant_reply.cy.ts @@ -11,6 +11,10 @@ describe("labeling assistant replies", () => { // For specific task pages the no task available result is normal. if (type === undefined) return; + cy.get('[data-cy="label-question"]').each((label) => { + // Click the no button, this generally approves the spam check + cy.wrap(label).find('[data-cy="no"]').click(); + }); cy.get('[data-cy="label-options"]').each((label) => { // Click the 4th option cy.wrap(label).find('[data-cy="radio-option"]').eq(3).click(); diff --git a/website/cypress/e2e/tasks/label_initial_prompt.cy.ts b/website/cypress/e2e/tasks/label_initial_prompt.cy.ts index be1cf9bb..f11a068d 100644 --- a/website/cypress/e2e/tasks/label_initial_prompt.cy.ts +++ b/website/cypress/e2e/tasks/label_initial_prompt.cy.ts @@ -11,6 +11,10 @@ describe("labeling initial prompts", () => { // For specific task pages the no task available result is normal. if (type === undefined) return; + cy.get('[data-cy="label-question"]').each((label) => { + // Click the no button, this generally approves the spam check + cy.wrap(label).find('[data-cy="no"]').click(); + }); cy.get('[data-cy="label-options"]').each((label) => { // Click the 4th option cy.wrap(label).find('[data-cy="radio-option"]').eq(3).click(); diff --git a/website/cypress/e2e/tasks/label_prompter_reply.cy.ts b/website/cypress/e2e/tasks/label_prompter_reply.cy.ts index a3c06cb3..23801b57 100644 --- a/website/cypress/e2e/tasks/label_prompter_reply.cy.ts +++ b/website/cypress/e2e/tasks/label_prompter_reply.cy.ts @@ -11,6 +11,10 @@ describe("labeling prompter replies", () => { // For specific task pages the no task available result is normal. if (type === undefined) return; + cy.get('[data-cy="label-question"]').each((label) => { + // Click the no button, this generally approves the spam check + cy.wrap(label).find('[data-cy="no"]').click(); + }); cy.get('[data-cy="label-options"]').each((label) => { // Click the 4th option cy.wrap(label).find('[data-cy="radio-option"]').eq(3).click(); diff --git a/website/cypress/e2e/tasks/random.cy.ts b/website/cypress/e2e/tasks/random.cy.ts index 0074bc53..0ca3c7f5 100644 --- a/website/cypress/e2e/tasks/random.cy.ts +++ b/website/cypress/e2e/tasks/random.cy.ts @@ -44,6 +44,10 @@ describe("handles random tasks", () => { break; } case "label-task": { + cy.get('[data-cy="label-question"]').each((label) => { + // Click the no button, this generally approves the spam check + cy.wrap(label).find('[data-cy="no"]').click(); + }); cy.get('[data-cy="label-options"]').each((label) => { // Click the 4th option cy.wrap(label).find('[data-cy="radio-option"]').eq(3).click(); @@ -55,15 +59,6 @@ describe("handles random tasks", () => { break; } - case "spam-task": { - cy.get('[data-cy="not-spam-button"]').click(); - - cy.get('[data-cy="review"]').click(); - - cy.get('[data-cy="submit"]').click(); - - break; - } case undefined: { throw new Error("No tasks available, but at least create initial prompt expected"); } diff --git a/website/public/locales/en/common.json b/website/public/locales/en/common.json index 0b0f9d37..f8e31c99 100644 --- a/website/public/locales/en/common.json +++ b/website/public/locales/en/common.json @@ -16,5 +16,7 @@ "sign_in": "Sign In", "sign_out": "Sign Out", "terms_of_service": "Terms of Service", - "title": "Open Assistant" + "title": "Open Assistant", + "yes": "Yes", + "no": "No" } diff --git a/website/public/locales/en/labelling.json b/website/public/locales/en/labelling.json new file mode 100644 index 00000000..13582c98 --- /dev/null +++ b/website/public/locales/en/labelling.json @@ -0,0 +1,16 @@ +{ + "label_highlighted_yes_no_instruction": "Answer the following question(s) about the highlighted message:", + "label_highlighted_flag_instruction": "Select any that apply to the highlighted message:", + "label_highlighted_likert_instruction": "Rate the highlighted message:", + "label_message_yes_no_instruction": "Answer the following question(s) about the message:", + "label_message_flag_instruction": "Select any that apply to the message:", + "label_message_likert_instruction": "Rate the message:", + "spam.question": "Is the message spam?", + "fails_task.question": "Does the reply fail the propmpters task?", + "not_appropriate": "Not Appropriate", + "pii": "Contains PII", + "hate_speech": "Hate Speech", + "sexual_content": "Sexual Content", + "moral_judgement": "Judges Morality", + "political_content": "Politcal" +} diff --git a/website/src/components/FlaggableElement.tsx b/website/src/components/FlaggableElement.tsx deleted file mode 100644 index d7572080..00000000 --- a/website/src/components/FlaggableElement.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { - Box, - Button, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Popover, - PopoverAnchor, - PopoverTrigger, - Tooltip, - useColorModeValue, - useDisclosure, -} from "@chakra-ui/react"; -import { AlertCircle } from "lucide-react"; -import { useState } from "react"; -import { get, post } from "src/lib/api"; -import { colors } from "src/styles/Theme/colors"; -import { Message } from "src/types/Conversation"; -import useSWRImmutable from "swr/immutable"; -import useSWRMutation from "swr/mutation"; - -import { LabelInputGroup } from "./Survey/LabelInputGroup"; - -interface Label { - name: string; - display_text: string; - help_text: string; -} - -interface FlaggableElementProps { - children: React.ReactNode; - message: Message; -} - -interface ValidLabelsResponse { - valid_labels: Label[]; -} - -export const FlaggableElement = (props: FlaggableElementProps) => { - const { data: response } = useSWRImmutable("/api/valid_labels", get); - const { isOpen, onOpen, onClose } = useDisclosure(); - const { valid_labels } = response || { valid_labels: [] }; - const [values, setValues] = useState([]); - - const submittable = - values.some((value) => { - return value !== null; - }) && - values.length === valid_labels.length && - valid_labels.length > 0; - - const { trigger } = useSWRMutation("/api/set_label", post, { - onSuccess: onClose, - onError: onClose, - }); - - const submitResponse = () => { - const label_map: Map = new Map(); - console.assert(valid_labels.length === values.length); - values.forEach((value, idx) => { - if (value !== null) { - label_map.set(valid_labels[idx].name, value); - } - }); - trigger({ - message_id: props.message.id, - label_map: Object.fromEntries(label_map), - text: props.message.text, - }); - }; - - return ( - - - {props.children} - - - - - - - - - - - - - - - Select one or more labels that apply. - - - name)} onChange={setValues} /> - - - - - - - - ); -}; diff --git a/website/src/components/Messages.tsx b/website/src/components/Messages.tsx index c9d77e3c..58e0d2be 100644 --- a/website/src/components/Messages.tsx +++ b/website/src/components/Messages.tsx @@ -1,25 +1,7 @@ -import { Box, forwardRef, Grid, useColorMode } from "@chakra-ui/react"; +import { Box, forwardRef, useColorMode } from "@chakra-ui/react"; import { useMemo } from "react"; import { Message } from "src/types/Conversation"; -import { FlaggableElement } from "./FlaggableElement"; - -interface MessagesProps { - messages: Message[]; -} - -export const Messages = ({ messages }: MessagesProps) => { - const items = messages.map((messageProps: Message, i: number) => { - return ( - - - - ); - }); - // Maybe also show a legend of the colors? - return {items}; -}; - export const MessageView = forwardRef, "div">((message: Partial, ref) => { const { colorMode } = useColorMode(); diff --git a/website/src/components/Messages/LabelFlagGroup.tsx b/website/src/components/Messages/LabelFlagGroup.tsx new file mode 100644 index 00000000..fb1158bc --- /dev/null +++ b/website/src/components/Messages/LabelFlagGroup.tsx @@ -0,0 +1,32 @@ +import { Button, Flex } from "@chakra-ui/react"; +import { useTranslation } from "next-i18next"; +import { getTypeSafei18nKey } from "src/lib/i18n"; + +interface LabelFlagGroupProps { + values: number[]; + labelNames: string[]; + isEditable?: boolean; + onChange: (values: number[]) => void; +} + +export const LabelFlagGroup = ({ values, labelNames, isEditable = true, onChange }: LabelFlagGroupProps) => { + const { t } = useTranslation("labelling"); + return ( + + {labelNames.map((name, idx) => ( + + ))} + + ); +}; diff --git a/website/src/components/Messages/LabelInputGroup.tsx b/website/src/components/Messages/LabelInputGroup.tsx new file mode 100644 index 00000000..51383128 --- /dev/null +++ b/website/src/components/Messages/LabelInputGroup.tsx @@ -0,0 +1,84 @@ +import { Text, VStack } from "@chakra-ui/react"; +import { Label } from "src/types/Tasks"; + +import { LabelLikertGroup } from "../Survey/LabelLikertGroup"; +import { LabelFlagGroup } from "./LabelFlagGroup"; +import { LabelYesNoGroup } from "./LabelYesNoGroup"; + +export interface LabelInputInstructions { + yesNoInstruction: string; + flagInstruction: string; + likertInstruction: string; +} + +interface LabelInputGroupProps { + values: number[]; + labels: Label[]; + requiredLabels?: string[]; + isEditable?: boolean; + instructions: LabelInputInstructions; + onChange: (values: number[]) => void; +} + +export const LabelInputGroup = ({ + labels, + values, + requiredLabels, + isEditable, + instructions, + onChange, +}: LabelInputGroupProps) => { + const yesNoIndexes = labels.map((label, idx) => (label.widget === "yes_no" ? idx : null)).filter((v) => v !== null); + const flagIndexes = labels.map((label, idx) => (label.widget === "flag" ? idx : null)).filter((v) => v !== null); + const likertIndexes = labels.map((label, idx) => (label.widget === "likert" ? idx : null)).filter((v) => v !== null); + + return ( + + {yesNoIndexes.length > 0 && ( + + {instructions.yesNoInstruction} + values[idx])} + labelNames={yesNoIndexes.map((idx) => labels[idx].name)} + isEditable={isEditable} + requiredLabels={requiredLabels} + onChange={(yesNoValues) => { + const newValues = values.slice(); + yesNoIndexes.forEach((idx, yesNoIndex) => (newValues[idx] = yesNoValues[yesNoIndex])); + onChange(newValues); + }} + /> + + )} + {flagIndexes.length > 0 && ( + + {instructions.flagInstruction} + values[idx])} + labelNames={flagIndexes.map((idx) => labels[idx].name)} + isEditable={isEditable} + onChange={(flagValues) => { + const newValues = values.slice(); + flagIndexes.forEach((idx, flagIndex) => (newValues[idx] = flagValues[flagIndex])); + onChange(newValues); + }} + /> + + )} + {likertIndexes.length > 0 && ( + + {instructions.likertInstruction} + labels[idx].name)} + isEditable={isEditable} + onChange={(likertValues) => { + const newValues = values.slice(); + likertIndexes.forEach((idx, likertIndex) => (newValues[idx] = likertValues[likertIndex])); + onChange(newValues); + }} + /> + + )} + + ); +}; diff --git a/website/src/components/Messages/LabelPopup.tsx b/website/src/components/Messages/LabelPopup.tsx index b2b95278..ac564e6d 100644 --- a/website/src/components/Messages/LabelPopup.tsx +++ b/website/src/components/Messages/LabelPopup.tsx @@ -9,9 +9,10 @@ import { ModalOverlay, } from "@chakra-ui/react"; import { useTranslation } from "next-i18next"; -import { useState } from "react"; -import { LabelInputGroup } from "src/components/Survey/LabelInputGroup"; +import { useEffect, useState } from "react"; +import { LabelInputGroup } from "src/components/Messages/LabelInputGroup"; import { get, post } from "src/lib/api"; +import { Label } from "src/types/Tasks"; import useSWRImmutable from "swr/immutable"; import useSWRMutation from "swr/mutation"; @@ -21,21 +22,19 @@ interface LabelMessagePopupProps { onClose: () => void; } -interface Label { - name: string; - display_text: string; - help_text: string; -} - interface ValidLabelsResponse { valid_labels: Label[]; } export const LabelMessagePopup = ({ messageId, show, onClose }: LabelMessagePopupProps) => { - const { t } = useTranslation("message"); + const { t } = useTranslation(); const { data: response } = useSWRImmutable("/api/valid_labels", get); const valid_labels = response?.valid_labels ?? []; - const [values, setValues] = useState(null); + const [values, setValues] = useState(new Array(valid_labels.length).fill(null)); + + useEffect(() => { + setValues(new Array(valid_labels.length).fill(null)); + }, [messageId, valid_labels.length]); const { trigger: setLabels } = useSWRMutation("/api/set_label", post); @@ -60,14 +59,23 @@ export const LabelMessagePopup = ({ messageId, show, onClose }: LabelMessagePopu - {t("label_title")} + {t("message:label_title")} - name)} onChange={setValues} /> + diff --git a/website/src/components/Messages/LabelYesNoGroup.tsx b/website/src/components/Messages/LabelYesNoGroup.tsx new file mode 100644 index 00000000..72c40e2b --- /dev/null +++ b/website/src/components/Messages/LabelYesNoGroup.tsx @@ -0,0 +1,89 @@ +import { Button, HStack, Text, Tooltip } from "@chakra-ui/react"; +import { useTranslation } from "next-i18next"; +import { getTypeSafei18nKey } from "src/lib/i18n"; + +interface LabelYesNoGroupProps { + values: number[]; + labelNames: string[]; + requiredLabels?: string[]; + isEditable?: boolean; + onChange: (values: number[]) => void; +} + +export const LabelYesNoGroup = ({ + values, + labelNames, + requiredLabels = [], + isEditable = true, + onChange, +}: LabelYesNoGroupProps) => { + const { t } = useTranslation("labelling"); + return ( + <> + {labelNames.map((name, idx) => { + return ( + 0.1 ? true : false} + onChange={(value) => { + const newValues = values.slice(); + newValues[idx] = value; + onChange(newValues); + }} + isEditable={isEditable} + isRequired={requiredLabels.includes(name)} + /> + ); + })} + + ); +}; + +const YesNoQuestion = ({ + isEditable, + question, + value, + isRequired, + onChange, +}: { + isEditable: boolean; + question: string; + value: boolean; + isRequired?: boolean; + onChange: (boolean) => void; +}) => { + const { t } = useTranslation(); + return ( +
+ + {question} + {isRequired ? : undefined} + + + + + +
+ ); +}; + +const RequiredMark = () => ( + + * + +); diff --git a/website/src/components/Messages/MessageTableEntry.tsx b/website/src/components/Messages/MessageTableEntry.tsx index 7202903a..2673ad49 100644 --- a/website/src/components/Messages/MessageTableEntry.tsx +++ b/website/src/components/Messages/MessageTableEntry.tsx @@ -36,7 +36,7 @@ export function MessageTableEntry({ message, enabled, highlight }: MessageTableE const router = useRouter(); const [emojiState, setEmojis] = useState({ emojis: {}, user_emojis: [] }); useEffect(() => { - setEmojis({ emojis: message.emojis, user_emojis: message.user_emojis }); + setEmojis({ emojis: message.emojis || {}, user_emojis: message.user_emojis || [] }); }, [message.emojis, message.user_emojis]); const goToMessage = useCallback(() => router.push(`/messages/${message.id}`), [router, message.id]); diff --git a/website/src/components/Survey/LabelInputGroup.tsx b/website/src/components/Survey/LabelLikertGroup.tsx similarity index 98% rename from website/src/components/Survey/LabelInputGroup.tsx rename to website/src/components/Survey/LabelLikertGroup.tsx index 94fcc48e..fc959a26 100644 --- a/website/src/components/Survey/LabelInputGroup.tsx +++ b/website/src/components/Survey/LabelLikertGroup.tsx @@ -135,7 +135,7 @@ const getLabelInfo = (label: string): LabelInfo => { oneDescription: ["Contains text which is incorrect or misleading"], inverted: true, }; - case "helpful": + case "helpfulness": return { zeroText: "Unhelful", zeroDescription: [], @@ -186,7 +186,7 @@ const getLabelInfo = (label: string): LabelInfo => { } }; -export const LabelInputGroup = ({ labelIDs, onChange, isEditable = true }: LabelInputGroupProps) => { +export const LabelLikertGroup = ({ labelIDs, onChange, isEditable = true }: LabelInputGroupProps) => { const [labelValues, setLabelValues] = useState(Array.from({ length: labelIDs.length }).map(() => null)); const cardColor = useColorModeValue("gray.50", "gray.800"); diff --git a/website/src/components/Survey/TaskControls.tsx b/website/src/components/Survey/TaskControls.tsx index 76aaee8b..a3c3ffdd 100644 --- a/website/src/components/Survey/TaskControls.tsx +++ b/website/src/components/Survey/TaskControls.tsx @@ -4,12 +4,10 @@ import { SkipButton } from "src/components/Buttons/Skip"; import { SubmitButton } from "src/components/Buttons/Submit"; import { TaskInfo } from "src/components/TaskInfo/TaskInfo"; import { TaskStatus } from "src/components/Tasks/Task"; +import { BaseTask } from "src/types/Task"; export interface TaskControlsProps { - // we need a task type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task: any; - className?: string; + task: BaseTask; taskStatus: TaskStatus; onEdit: () => void; onReview: () => void; @@ -17,7 +15,7 @@ export interface TaskControlsProps { onSkip: (reason: string) => void; } -export const TaskControls = (props: TaskControlsProps) => { +export const TaskControls = ({ task, taskStatus, onEdit, onReview, onSubmit, onSkip }: TaskControlsProps) => { const backgroundColor = useColorModeValue("white", "gray.800"); return ( @@ -31,38 +29,32 @@ export const TaskControls = (props: TaskControlsProps) => { shadow="base" gap="4" > - + - {props.taskStatus === "REVIEW" || props.taskStatus === "SUBMITTED" ? ( + {taskStatus.mode === "EDIT" ? ( <> - - } - /> - + - Submit + Review ) : ( <> - + + } /> + - Review + Submit )} diff --git a/website/src/components/Tasks/CreateTask.tsx b/website/src/components/Tasks/CreateTask.tsx index 36493e27..5276a183 100644 --- a/website/src/components/Tasks/CreateTask.tsx +++ b/website/src/components/Tasks/CreateTask.tsx @@ -7,6 +7,8 @@ import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards"; import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskHeader } from "src/components/Tasks/TaskHeader"; import { getTypeSafei18nKey } from "src/lib/i18n"; +import { TaskType } from "src/types/Task"; +import { CreateAssistantReplyTask, CreateInitialPromptTask, CreatePrompterReplyTask } from "src/types/Tasks"; export const CreateTask = ({ task, @@ -15,7 +17,7 @@ export const CreateTask = ({ isDisabled, onReplyChanged, onValidityChanged, -}: TaskSurveyProps<{ text: string }>) => { +}: TaskSurveyProps) => { const { t, i18n } = useTranslation(["tasks", "common"]); const cardColor = useColorModeValue("gray.50", "gray.800"); const titleColor = useColorModeValue("gray.800", "gray.300"); @@ -39,7 +41,7 @@ export const CreateTask = ({ <> - {!!task.conversation && ( + {task.type !== TaskType.initial_prompt && ( diff --git a/website/src/components/Tasks/EvaluateTask.tsx b/website/src/components/Tasks/EvaluateTask.tsx index 4be86dd0..b554ffd4 100644 --- a/website/src/components/Tasks/EvaluateTask.tsx +++ b/website/src/components/Tasks/EvaluateTask.tsx @@ -5,6 +5,8 @@ import { Sortable } from "src/components/Sortable/Sortable"; import { SurveyCard } from "src/components/Survey/SurveyCard"; import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskHeader } from "src/components/Tasks/TaskHeader"; +import { TaskType } from "src/types/Task"; +import { RankAssistantRepliesTask, RankInitialPromptsTask, RankPrompterRepliesTask } from "src/types/Tasks"; export const EvaluateTask = ({ task, @@ -13,19 +15,25 @@ export const EvaluateTask = ({ isDisabled, onReplyChanged, onValidityChanged, -}: TaskSurveyProps<{ ranking: number[] }>) => { +}: TaskSurveyProps< + RankInitialPromptsTask | RankAssistantRepliesTask | RankPrompterRepliesTask, + { ranking: number[] } +>) => { const cardColor = useColorModeValue("gray.50", "gray.800"); const [ranking, setRanking] = useState(null); let messages = []; - if (task.conversation) { + if (task.type !== TaskType.rank_initial_prompts) { messages = task.conversation.messages; } useEffect(() => { if (ranking === null) { - const defaultRanking = (task.replies ?? task.prompts).map((_, idx) => idx); - onReplyChanged({ ranking: defaultRanking }); + if (task.type === TaskType.rank_initial_prompts) { + onReplyChanged({ ranking: task.prompts.map((_, idx) => idx) }); + } else { + onReplyChanged({ ranking: task.replies.map((_, idx) => idx) }); + } onValidityChanged("DEFAULT"); } else { onReplyChanged({ ranking }); @@ -33,7 +41,7 @@ export const EvaluateTask = ({ } }, [task, ranking, onReplyChanged, onValidityChanged]); - const sortables = task.replies ? "replies" : "prompts"; + const sortables = task.type === TaskType.rank_initial_prompts ? "prompts" : "replies"; return (
diff --git a/website/src/components/Tasks/LabelTask/LabelTask.tsx b/website/src/components/Tasks/LabelTask/LabelTask.tsx index 10ea76fb..33152ba1 100644 --- a/website/src/components/Tasks/LabelTask/LabelTask.tsx +++ b/website/src/components/Tasks/LabelTask/LabelTask.tsx @@ -1,11 +1,18 @@ -import { Box, Button, Flex, HStack, Text, useColorModeValue } from "@chakra-ui/react"; +import { Box, useBoolean, useColorModeValue } from "@chakra-ui/react"; +import { useTranslation } from "next-i18next"; import { useEffect, useState } from "react"; import { MessageView } from "src/components/Messages"; +import { LabelInputGroup } from "src/components/Messages/LabelInputGroup"; import { MessageTable } from "src/components/Messages/MessageTable"; -import { LabelInputGroup } from "src/components/Survey/LabelInputGroup"; import { TwoColumnsWithCards } from "src/components/Survey/TwoColumnsWithCards"; import { TaskSurveyProps } from "src/components/Tasks/Task"; import { TaskHeader } from "src/components/Tasks/TaskHeader"; +import { TaskType } from "src/types/Task"; +import { LabelTaskType } from "src/types/Tasks"; + +const isRequired = (labelName: string, requiredLabels?: string[]) => { + return requiredLabels ? requiredLabels.includes(labelName) : false; +}; export const LabelTask = ({ task, @@ -13,15 +20,33 @@ export const LabelTask = ({ isEditable, onReplyChanged, onValidityChanged, -}: TaskSurveyProps<{ text: string; labels: Record; message_id: string }>) => { - const [sliderValues, setSliderValues] = useState(new Array(task.valid_labels.length).fill(null)); +}: TaskSurveyProps; message_id: string }>) => { + const { t } = useTranslation("labelling"); + const [values, setValues] = useState(new Array(task.labels.length).fill(null)); + const [userInputMade, setUserInputMade] = useBoolean(false); + // Initial setup to run when the task changes useEffect(() => { - console.assert(task.valid_labels.length === sliderValues.length); - const labels = Object.fromEntries(task.valid_labels.map((label, i) => [label, sliderValues[i]])); - onReplyChanged({ labels, text: task.reply || task.prompt, message_id: task.message_id }); - onValidityChanged(sliderValues.every((value) => value !== null) ? "VALID" : "INVALID"); - }, [task, sliderValues, onReplyChanged, onValidityChanged]); + setValues(new Array(task.labels.length).fill(null)); + onValidityChanged(task.labels.some(({ name }) => isRequired(name, task.mandatory_labels)) ? "INVALID" : "DEFAULT"); + setUserInputMade.off(); + }, [task, setUserInputMade, onValidityChanged]); + + // Update the reply and validity when the values change + useEffect(() => { + onReplyChanged({ + text: "unused?", + labels: Object.fromEntries(task.labels.map(({ name }, idx) => [name, values[idx] || 0])), + message_id: task.message_id, + }); + onValidityChanged( + task.labels.some(({ name }, idx) => values[idx] === null && isRequired(name, task.mandatory_labels)) + ? "INVALID" + : userInputMade + ? "VALID" + : "DEFAULT" + ); + }, [task, values, onReplyChanged, userInputMade, onValidityChanged]); const cardColor = useColorModeValue("gray.50", "gray.800"); const isSpamTask = task.mode === "simple" && task.valid_labels.length === 1 && task.valid_labels[0] === "spam"; @@ -31,12 +56,9 @@ export const LabelTask = ({ <> - {task.conversation ? ( + {task.type !== TaskType.label_initial_prompt ? ( - + ) : ( @@ -44,51 +66,22 @@ export const LabelTask = ({ )} - {isSpamTask ? ( - setSliderValues([value])} - isEditable={isEditable} - /> - ) : ( - - The highlighted message: - - - )} + { + setValues(values); + setUserInputMade.on(); + }} + />
); }; - -const SpamTaskInput = ({ - isEditable, - value, - onChange, -}: { - isEditable: boolean; - value: number; - onChange: (number) => void; -}) => { - return ( - - Is the highlighted message spam? - - - - ); -}; diff --git a/website/src/components/Tasks/Task/Task.tsx b/website/src/components/Tasks/Task/Task.tsx index ae82ef97..51ba6fa3 100644 --- a/website/src/components/Tasks/Task/Task.tsx +++ b/website/src/components/Tasks/Task/Task.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "next-i18next"; -import { useRef, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; +import { useMemo, useRef } from "react"; import { TaskControls } from "src/components/Survey/TaskControls"; import { CreateTask } from "src/components/Tasks/CreateTask"; import { EvaluateTask } from "src/components/Tasks/EvaluateTask"; @@ -8,15 +9,52 @@ import { TaskCategory, TaskInfo, TaskInfos } from "src/components/Tasks/TaskType import { UnchangedWarning } from "src/components/Tasks/UnchangedWarning"; import { post } from "src/lib/api"; import { getTypeSafei18nKey } from "src/lib/i18n"; -import { TaskContent, TaskReplyValidity } from "src/types/Task"; +import { BaseTask, TaskContent, TaskReplyValidity } from "src/types/Task"; import useSWRMutation from "swr/mutation"; -export type TaskStatus = "NOT_SUBMITTABLE" | "DEFAULT" | "VALID" | "REVIEW" | "SUBMITTED"; +interface EditMode { + mode: "EDIT"; + replyValidity: TaskReplyValidity; +} +interface ReviewMode { + mode: "REVIEW"; +} +interface DefaultWarnMode { + mode: "DEFAULT_WARN"; +} +interface SubmittedMode { + mode: "SUBMITTED"; +} -export interface TaskSurveyProps { - // we need a task type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task: any; +export type TaskStatus = EditMode | DefaultWarnMode | ReviewMode | SubmittedMode; + +interface NewTask { + action: "NEW_TASK"; +} + +interface Review { + action: "REVIEW"; +} + +interface SetSubmitted { + action: "SET_SUBMITTED"; +} + +interface ReturnToEdit { + action: "RETURN_EDIT"; +} + +interface AcceptDefault { + action: "ACCEPT_DEFAULT"; +} + +interface UpdateValidity { + action: "UPDATE_VALIDITY"; + replyValidity: TaskReplyValidity; +} + +export interface TaskSurveyProps { + task: TaskType; taskType: TaskInfo; isEditable: boolean; isDisabled?: boolean; @@ -26,13 +64,63 @@ export interface TaskSurveyProps { export const Task = ({ frontendId, task, trigger, mutate }) => { const { t } = useTranslation("tasks"); - const [taskStatus, setTaskStatus] = useState("NOT_SUBMITTABLE"); + const [taskStatus, taskEvent] = useReducer( + ( + status: TaskStatus, + event: NewTask | UpdateValidity | AcceptDefault | Review | ReturnToEdit | SetSubmitted + ): TaskStatus => { + switch (event.action) { + case "NEW_TASK": + return { mode: "EDIT", replyValidity: "INVALID" }; + case "UPDATE_VALIDITY": + return status.mode === "EDIT" ? { mode: "EDIT", replyValidity: event.replyValidity } : status; + case "ACCEPT_DEFAULT": + return status.mode === "DEFAULT_WARN" ? { mode: "REVIEW" } : status; + case "REVIEW": { + if (status.mode === "EDIT") { + switch (status.replyValidity) { + case "DEFAULT": + return { mode: "DEFAULT_WARN" }; + case "VALID": + return { mode: "REVIEW" }; + } + } + return status; + } + case "RETURN_EDIT": { + switch (status.mode) { + case "REVIEW": + return { mode: "EDIT", replyValidity: "VALID" }; + case "DEFAULT_WARN": + return { mode: "EDIT", replyValidity: "DEFAULT" }; + default: + return status; + } + } + case "SET_SUBMITTED": { + return status.mode === "REVIEW" ? { mode: "SUBMITTED" } : status; + } + } + }, + { mode: "EDIT", replyValidity: "INVALID" } + ); + const replyContent = useRef(null); - const [showUnchangedWarning, setShowUnchangedWarning] = useState(false); + const updateValidity = useCallback( + (replyValidity: TaskReplyValidity) => taskEvent({ action: "UPDATE_VALIDITY", replyValidity }), + [taskEvent] + ); + + useEffect(() => { + taskEvent({ action: "NEW_TASK" }); + }, [task.id, updateValidity]); const rootEl = useRef(null); - const taskType = TaskInfos.find((taskType) => taskType.type === task.type && taskType.mode === task.mode); + const taskType = useMemo( + () => TaskInfos.find((taskType) => taskType.type === task.type && taskType.mode === task.mode), + [task.type, task.mode] + ); const { trigger: sendRejection } = useSWRMutation("/api/reject_task", post, { onSuccess: async () => { @@ -47,79 +135,36 @@ export const Task = ({ frontendId, task, trigger, mutate }) => { }); }; - const edit_mode = taskStatus === "NOT_SUBMITTABLE" || taskStatus === "DEFAULT" || taskStatus === "VALID"; - const submitted = taskStatus === "SUBMITTED"; - - const onValidityChanged = (validity: TaskReplyValidity) => { - if (!edit_mode) return; - switch (validity) { - case "DEFAULT": - if (taskStatus !== "DEFAULT") setTaskStatus("DEFAULT"); - break; - case "VALID": - if (taskStatus !== "VALID") setTaskStatus("VALID"); - break; - case "INVALID": - if (taskStatus !== "NOT_SUBMITTABLE") setTaskStatus("NOT_SUBMITTABLE"); - break; - } - }; - - const onReplyChanged = (content: TaskContent) => { - replyContent.current = content; - }; - - const reviewResponse = () => { - switch (taskStatus) { - case "DEFAULT": - setShowUnchangedWarning(true); - break; - case "VALID": - setTaskStatus("REVIEW"); - break; - default: - return; - } - }; - - const editResponse = () => { - switch (taskStatus) { - case "REVIEW": - setTaskStatus("VALID"); - break; - default: - return; - } - }; + const onReplyChanged = useCallback( + (content: TaskContent) => { + replyContent.current = content; + }, + [replyContent] + ); const submitResponse = () => { - switch (taskStatus) { - case "REVIEW": { - trigger({ - id: frontendId, - update_type: taskType.update_type, - content: replyContent.current, - }); - setTaskStatus("SUBMITTED"); - scrollToTop(rootEl.current); - break; - } - default: - return; + if (taskStatus.mode === "REVIEW") { + trigger({ + id: frontendId, + update_type: taskType.update_type, + content: replyContent.current, + }); + taskEvent({ action: "SET_SUBMITTED" }); + scrollToTop(rootEl.current); } }; - function taskTypeComponent() { + const taskTypeComponent = useMemo(() => { switch (taskType.category) { case TaskCategory.Create: return ( ); case TaskCategory.Evaluate: @@ -127,10 +172,10 @@ export const Task = ({ frontendId, task, trigger, mutate }) => { ); case TaskCategory.Label: @@ -138,37 +183,34 @@ export const Task = ({ frontendId, task, trigger, mutate }) => { ); } - } + }, [task, taskType, taskStatus.mode, onReplyChanged, updateValidity]); return (
- {taskTypeComponent()} + {taskTypeComponent} taskEvent({ action: "RETURN_EDIT" })} + onReview={() => taskEvent({ action: "REVIEW" })} onSubmit={submitResponse} onSkip={rejectTask} /> setShowUnchangedWarning(false)} + onClose={() => taskEvent({ action: "RETURN_EDIT" })} onContinueAnyway={() => { - if (taskStatus === "DEFAULT") { - setTaskStatus("REVIEW"); - setShowUnchangedWarning(false); - } + taskEvent({ action: "ACCEPT_DEFAULT" }); }} />
diff --git a/website/src/types/Tasks.ts b/website/src/types/Tasks.ts index bbbe3a67..5fbc84c7 100644 --- a/website/src/types/Tasks.ts +++ b/website/src/types/Tasks.ts @@ -33,31 +33,39 @@ export interface RankPrompterRepliesTask extends BaseTask { replies: string[]; } -export interface LabelAssistantReplyTask extends BaseTask { +export interface Label { + display_text: string; + help_text: string; + name: string; + widget: "flag" | "yes_no" | "likert"; +} + +export interface BaseLabelTask extends BaseTask { + message_id: string; + labels: Label[]; + valid_labels: string[]; + disposition: "spam" | "quality"; + mode: "simple" | "full"; + mandatory_labels?: string[]; +} + +export interface LabelAssistantReplyTask extends BaseLabelTask { type: TaskType.label_assistant_reply; - message_id: string; conversation: Conversation; reply_message: Message; reply: string; - valid_labels: string[]; - mode: "simple" | "full"; - mandatory_labels?: string[]; } -export interface LabelPrompterReplyTask extends BaseTask { +export interface LabelPrompterReplyTask extends BaseLabelTask { type: TaskType.label_prompter_reply; - message_id: string; conversation: Conversation; reply_message: Message; reply: string; - valid_labels: string[]; - mode: "simple" | "full"; - mandatory_labels?: string[]; } -export interface LabelInitialPromptTask extends BaseTask { +export interface LabelInitialPromptTask extends BaseLabelTask { type: TaskType.label_initial_prompt; - message_id: string; - valid_labels: string[]; prompt: string; } + +export type LabelTaskType = LabelAssistantReplyTask | LabelPrompterReplyTask | LabelInitialPromptTask; diff --git a/website/types/i18next.d.ts b/website/types/i18next.d.ts index a00b1a80..0a2cf10a 100644 --- a/website/types/i18next.d.ts +++ b/website/types/i18next.d.ts @@ -3,6 +3,7 @@ import type dashboard from "public/locales/en/dashboard.json"; import type index from "public/locales/en/index.json"; import type leaderboard from "public/locales/en/leaderboard.json"; import type message from "public/locales/en/message.json"; +import type labelling from "public/locales/en/labelling.json"; import type tasks from "public/locales/en/tasks.json"; declare module "i18next" { @@ -14,6 +15,7 @@ declare module "i18next" { leaderboard: typeof leaderboard; tasks: typeof tasks; message: typeof message; + labelling: typeof labelling; }; } }