diff --git a/client/payloads.go b/client/payloads.go index abaf564..8e6d163 100644 --- a/client/payloads.go +++ b/client/payloads.go @@ -48,6 +48,8 @@ const ( InstructionTypeFreeText InstructionType = "free_text" // InstructionTypeMultipleChoiceWithFreeText represents a multiple choice instruction with free text. InstructionTypeMultipleChoiceWithFreeText InstructionType = "multiple_choice_with_free_text" + // InstructionTypeMultipleChoiceWithUnit represents a multiple choice instruction with unit selection. + InstructionTypeMultipleChoiceWithUnit InstructionType = "multiple_choice_with_unit" ) // InstructionOption represents an option for multiple choice instructions @@ -57,6 +59,12 @@ type InstructionOption struct { Heading string `json:"heading,omitempty"` } +// UnitOption represents a unit option for multiple_choice_with_unit instructions +type UnitOption struct { + Label string `json:"label"` + Value string `json:"value"` +} + // AnswerLimit represents the answer limit for multiple choice with free text instructions type AnswerLimit struct { Type string `json:"type"` @@ -70,6 +78,8 @@ type Instruction struct { Description string `json:"description"` Options []InstructionOption `json:"options,omitempty"` AnswerLimit *AnswerLimit `json:"answer_limit,omitempty"` + UnitOptions []UnitOption `json:"unit_options,omitempty"` + DefaultUnit string `json:"default_unit,omitempty"` } // CreateAITaskBuilderInstructionsPayload represents the JSON payload for creating AI Task Builder instructions diff --git a/cmd/aitaskbuilder/batch_instructions.go b/cmd/aitaskbuilder/batch_instructions.go index 9b280b0..9f3301d 100644 --- a/cmd/aitaskbuilder/batch_instructions.go +++ b/cmd/aitaskbuilder/batch_instructions.go @@ -35,7 +35,8 @@ a JSON file or as a JSON string directly. The instructions should be an array of instruction objects with the following types: - multiple_choice: Instructions with predefined options - free_text: Instructions requiring text input -- multiple_choice_with_free_text: Instructions with options and text input`, +- multiple_choice_with_free_text: Instructions with options and text input +- multiple_choice_with_unit: Instructions with options and unit selection (e.g., height in cm/inches)`, Example: ` Add instructions from a file: $ prolific aitaskbuilder batch instructions -b -f instructions.json @@ -154,6 +155,9 @@ func createBatchInstructions(c client.API, opts BatchInstructionsOptions, w io.W if len(instruction.Options) > 0 { fmt.Fprintf(w, " Options: %d\n", len(instruction.Options)) } + if len(instruction.UnitOptions) > 0 { + fmt.Fprintf(w, " Unit Options: %d\n", len(instruction.UnitOptions)) + } } return nil @@ -169,6 +173,7 @@ func validateInstructions(instructions client.CreateAITaskBuilderInstructionsPay client.InstructionTypeMultipleChoice: true, client.InstructionTypeFreeText: true, client.InstructionTypeMultipleChoiceWithFreeText: true, + client.InstructionTypeMultipleChoiceWithUnit: true, } for i, instruction := range instructions.Instructions { @@ -177,7 +182,7 @@ func validateInstructions(instructions client.CreateAITaskBuilderInstructionsPay } if !validTypes[instruction.Type] { - return fmt.Errorf("instruction %d: invalid type '%s'. Must be one of: multiple_choice, free_text, multiple_choice_with_free_text", i+1, instruction.Type) + return fmt.Errorf("instruction %d: invalid type '%s'. Must be one of: multiple_choice, free_text, multiple_choice_with_free_text, multiple_choice_with_unit", i+1, instruction.Type) } if instruction.CreatedBy == "" { @@ -189,12 +194,43 @@ func validateInstructions(instructions client.CreateAITaskBuilderInstructionsPay } // Validate type-specific requirements - if instruction.Type == client.InstructionTypeMultipleChoice || instruction.Type == client.InstructionTypeMultipleChoiceWithFreeText { + if instruction.Type == client.InstructionTypeMultipleChoice || + instruction.Type == client.InstructionTypeMultipleChoiceWithFreeText || + instruction.Type == client.InstructionTypeMultipleChoiceWithUnit { if len(instruction.Options) == 0 { return fmt.Errorf("instruction %d: options are required for type '%s'", i+1, instruction.Type) } } + // Validate unit_options for multiple_choice_with_unit + if instruction.Type == client.InstructionTypeMultipleChoiceWithUnit { + if len(instruction.UnitOptions) < 2 { + return fmt.Errorf("instruction %d: unit_options requires at least 2 options for type 'multiple_choice_with_unit'", i+1) + } + for j, unitOption := range instruction.UnitOptions { + if unitOption.Label == "" { + return fmt.Errorf("instruction %d, unit_option %d: label is required", i+1, j+1) + } + if unitOption.Value == "" { + return fmt.Errorf("instruction %d, unit_option %d: value is required", i+1, j+1) + } + } + // Validate default_unit is required and matches one of the unit_options values + if instruction.DefaultUnit == "" { + return fmt.Errorf("instruction %d: default_unit is required for type 'multiple_choice_with_unit'", i+1) + } + validUnit := false + for _, opt := range instruction.UnitOptions { + if opt.Value == instruction.DefaultUnit { + validUnit = true + break + } + } + if !validUnit { + return fmt.Errorf("instruction %d: default_unit '%s' must match one of the unit_options values", i+1, instruction.DefaultUnit) + } + } + // Validate options if present for j, option := range instruction.Options { if option.Label == "" { diff --git a/cmd/aitaskbuilder/batch_instructions_test.go b/cmd/aitaskbuilder/batch_instructions_test.go index 7249776..b4ea522 100644 --- a/cmd/aitaskbuilder/batch_instructions_test.go +++ b/cmd/aitaskbuilder/batch_instructions_test.go @@ -362,3 +362,301 @@ func TestNewBatchInstructionsCommandInvalidInstructionType(t *testing.T) { t.Fatalf("expected error about invalid type; got %s", err.Error()) } } + +func TestNewBatchInstructionsCommandWithMultipleChoiceWithUnit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e12346" + + response := client.CreateAITaskBuilderInstructionsResponse{ + model.Instruction{ + ID: "inst-789", + Type: "multiple_choice_with_unit", + BatchID: batchID, + CreatedBy: "Sean", + CreatedAt: "2024-09-18T07:50:15.055Z", + Description: "What is your height?", + Options: []model.InstructionOption{ + {Label: "150", Value: "150"}, + {Label: "160", Value: "160"}, + }, + UnitOptions: []model.UnitOption{ + {Label: "CM", Value: "cm"}, + {Label: "Inches", Value: "in"}, + }, + DefaultUnit: "cm", + }, + } + + instructions := client.CreateAITaskBuilderInstructionsPayload{ + Instructions: []client.Instruction{ + { + Type: "multiple_choice_with_unit", + CreatedBy: "Sean", + Description: "What is your height?", + Options: []client.InstructionOption{ + {Label: "150", Value: "150"}, + {Label: "160", Value: "160"}, + }, + UnitOptions: []client.UnitOption{ + {Label: "CM", Value: "cm"}, + {Label: "Inches", Value: "in"}, + }, + DefaultUnit: "cm", + }, + }, + } + + c.EXPECT().CreateAITaskBuilderInstructions(batchID, instructions).Return(&response, nil) + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + instructionsJSON := `[{ + "type": "multiple_choice_with_unit", + "created_by": "Sean", + "description": "What is your height?", + "options": [ + {"label": "150", "value": "150"}, + {"label": "160", "value": "160"} + ], + "unit_options": [ + {"label": "CM", "value": "cm"}, + {"label": "Inches", "value": "in"} + ], + "default_unit": "cm" + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err != nil { + t.Fatalf("expected no error; got %s", err.Error()) + } + + writer.Flush() + + expectedOutput := "Successfully added 1 instruction(s) to batch " + batchID + if !strings.Contains(buf.String(), expectedOutput) { + t.Fatalf("expected output to contain '%s'; got %s", expectedOutput, buf.String()) + } + + expectedID := "ID: inst-789" + if !strings.Contains(buf.String(), expectedID) { + t.Fatalf("expected output to contain '%s'; got %s", expectedID, buf.String()) + } + + expectedUnitOptions := "Unit Options: 2" + if !strings.Contains(buf.String(), expectedUnitOptions) { + t.Fatalf("expected output to contain '%s'; got %s", expectedUnitOptions, buf.String()) + } +} + +func TestNewBatchInstructionsCommandMultipleChoiceWithUnitMissingUnitOptions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e23700" + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + // Missing unit_options + instructionsJSON := `[{ + "type": "multiple_choice_with_unit", + "created_by": "Sean", + "description": "What is your height?", + "options": [ + {"label": "150", "value": "150"}, + {"label": "160", "value": "160"} + ] + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error; got nil") + } + + if !strings.Contains(err.Error(), "unit_options requires at least 2 options") { + t.Fatalf("expected error about missing unit_options; got %s", err.Error()) + } +} + +func TestNewBatchInstructionsCommandMultipleChoiceWithUnitInsufficientUnitOptions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e23701" + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + // Only 1 unit_option (need at least 2) + instructionsJSON := `[{ + "type": "multiple_choice_with_unit", + "created_by": "Sean", + "description": "What is your height?", + "options": [ + {"label": "150", "value": "150"}, + {"label": "160", "value": "160"} + ], + "unit_options": [ + {"label": "CM", "value": "cm"} + ] + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error; got nil") + } + + if !strings.Contains(err.Error(), "unit_options requires at least 2 options") { + t.Fatalf("expected error about insufficient unit_options; got %s", err.Error()) + } +} + +func TestNewBatchInstructionsCommandMultipleChoiceWithUnitMissingOptions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e23702" + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + // Missing options (required for multiple_choice_with_unit) + instructionsJSON := `[{ + "type": "multiple_choice_with_unit", + "created_by": "Sean", + "description": "What is your height?", + "unit_options": [ + {"label": "CM", "value": "cm"}, + {"label": "Inches", "value": "in"} + ] + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error; got nil") + } + + if !strings.Contains(err.Error(), "options are required for type 'multiple_choice_with_unit'") { + t.Fatalf("expected error about missing options; got %s", err.Error()) + } +} + +func TestNewBatchInstructionsCommandMultipleChoiceWithUnitInvalidDefaultUnit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e23703" + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + // default_unit doesn't match any unit_options value + instructionsJSON := `[{ + "type": "multiple_choice_with_unit", + "created_by": "Sean", + "description": "What is your height?", + "options": [ + {"label": "150", "value": "150"}, + {"label": "160", "value": "160"} + ], + "unit_options": [ + {"label": "CM", "value": "cm"}, + {"label": "Inches", "value": "in"} + ], + "default_unit": "meters" + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error; got nil") + } + + if !strings.Contains(err.Error(), "default_unit 'meters' must match one of the unit_options values") { + t.Fatalf("expected error about invalid default_unit; got %s", err.Error()) + } +} + +func TestNewBatchInstructionsCommandMultipleChoiceWithUnitMissingDefaultUnit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e23703" + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + // default_unit is missing + instructionsJSON := `[{ + "type": "multiple_choice_with_unit", + "created_by": "Sean", + "description": "What is your height?", + "options": [ + {"label": "150", "value": "150"}, + {"label": "160", "value": "160"} + ], + "unit_options": [ + {"label": "CM", "value": "cm"}, + {"label": "Inches", "value": "in"} + ] + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error; got nil") + } + + if !strings.Contains(err.Error(), "default_unit is required for type 'multiple_choice_with_unit'") { + t.Fatalf("expected error about missing default_unit; got %s", err.Error()) + } +} diff --git a/cmd/aitaskbuilder/get_task_responses.go b/cmd/aitaskbuilder/get_task_responses.go index 44f1178..89bcd8c 100644 --- a/cmd/aitaskbuilder/get_task_responses.go +++ b/cmd/aitaskbuilder/get_task_responses.go @@ -129,6 +129,20 @@ func renderAITaskBuilderResponses(c client.API, opts BatchGetResponsesOptions, w } else { fmt.Fprintf(w, " Additional Text: \n") } + case model.AITaskBuilderResponseTypeMultipleChoiceWithUnit: + if len(resp.Response.Answer) > 0 { + fmt.Fprintf(w, " Selected Options:\n") + for _, option := range resp.Response.Answer { + fmt.Fprintf(w, " - %s\n", option.Value) + } + } else { + fmt.Fprintf(w, " Selected Options: \n") + } + if resp.Response.Unit != nil { + fmt.Fprintf(w, " Unit: %s\n", *resp.Response.Unit) + } else { + fmt.Fprintf(w, " Unit: \n") + } } if i < len(response.Results)-1 { diff --git a/cmd/collection/constants.go b/cmd/collection/constants.go index a65d242..562e39a 100644 --- a/cmd/collection/constants.go +++ b/cmd/collection/constants.go @@ -2,11 +2,15 @@ package collection const ( // Error messages - ErrCollectionItemsRequired = "at least one collection item must be provided" - ErrPageItemsRequired = "each page must have at least one item in page_items" - ErrWorkspaceIDRequired = "workspace ID is required" - ErrNameRequired = "name is required" - ErrWorkspaceNotFound = "workspace not found" + ErrCollectionItemsRequired = "at least one collection item must be provided" + ErrPageItemsRequired = "each page must have at least one item in page_items" + ErrWorkspaceIDRequired = "workspace ID is required" + ErrNameRequired = "name is required" + ErrWorkspaceNotFound = "workspace not found" + ErrTaskDetailsRequired = "task_details is required" + ErrTaskNameRequired = "task_details.task_name is required" + ErrTaskIntroductionRequired = "task_details.task_introduction is required" + ErrTaskStepsRequired = "task_details.task_steps is required" // Feature access constants for AI Task Builder Collections (see DCP-2152) FeatureNameAITBCollection = "AI Task Builder Collections" diff --git a/cmd/collection/create_collection.go b/cmd/collection/create_collection.go index 42c2401..a84419c 100644 --- a/cmd/collection/create_collection.go +++ b/cmd/collection/create_collection.go @@ -139,6 +139,23 @@ func validatePayload(payload model.CreateAITaskBuilderCollection) error { return errors.New(ErrCollectionItemsRequired) } + // Validate task_details + if payload.TaskDetails == nil { + return errors.New(ErrTaskDetailsRequired) + } + + if payload.TaskDetails.TaskName == "" { + return errors.New(ErrTaskNameRequired) + } + + if payload.TaskDetails.TaskIntroduction == "" { + return errors.New(ErrTaskIntroductionRequired) + } + + if payload.TaskDetails.TaskSteps == "" { + return errors.New(ErrTaskStepsRequired) + } + return nil } diff --git a/cmd/collection/create_collection_test.go b/cmd/collection/create_collection_test.go index 9a90acd..66681c8 100644 --- a/cmd/collection/create_collection_test.go +++ b/cmd/collection/create_collection_test.go @@ -46,6 +46,12 @@ const collectionItems = `[ } ]` +const taskDetails = `{ + "task_name": "Test Task Name", + "task_introduction": "Test task introduction", + "task_steps": "Test task steps" + }` + const collectionItemsWithContentBlocks = `[ { "order": 0, @@ -104,10 +110,11 @@ func TestNewCreateCollectionCommandCallsAPIWithJSON(t *testing.T) { tmpDir := t.TempDir() templateFile := filepath.Join(tmpDir, "collection.json") templateContent := fmt.Sprintf(`{ - "workspace_id": "6716028cd934ced9bac18658", + "workspace_id": "%s", "name": "test-collection", - "collection_items": %s -}`, collectionItems) + "task_details": %s, + "collection_items": %s +}`, testWorkspaceID, taskDetails, collectionItems) err := os.WriteFile(templateFile, []byte(templateContent), 0600) if err != nil { @@ -118,7 +125,7 @@ func TestNewCreateCollectionCommandCallsAPIWithJSON(t *testing.T) { response := client.CreateAITaskBuilderCollectionResponse{ ID: "collection-123", Name: "test-collection", - WorkspaceID: "6716028cd934ced9bac18658", + WorkspaceID: testWorkspaceID, SchemaVersion: 1, CreatedBy: "user-456", CollectionItems: []model.CollectionPage{ @@ -162,7 +169,7 @@ func TestNewCreateCollectionCommandCallsAPIWithJSON(t *testing.T) { "Collection created successfully!", "ID: collection-123", "Name: test-collection", - "Workspace ID: 6716028cd934ced9bac18658", + "Workspace ID: " + testWorkspaceID, "Schema Version: 1", "Created By: user-456", "Pages: 1", @@ -183,7 +190,7 @@ func TestNewCreateCollectionCommandCallsAPIWithYAML(t *testing.T) { // Create temporary YAML test file tmpDir := t.TempDir() templateFile := filepath.Join(tmpDir, "collection.yaml") - templateContent := `workspace_id: 6716028cd934ced9bac18658 + templateContent := `workspace_id: ` + testWorkspaceID + ` name: yaml-test-collection task_details: task_name: YAML Task Name @@ -226,7 +233,7 @@ collection_items: response := client.CreateAITaskBuilderCollectionResponse{ ID: "collection-yaml-123", Name: "yaml-test-collection", - WorkspaceID: "6716028cd934ced9bac18658", + WorkspaceID: testWorkspaceID, SchemaVersion: 1, CreatedBy: "user-789", CollectionItems: []model.CollectionPage{}, @@ -335,10 +342,11 @@ func TestNewCreateCollectionCommandHandlesAPIError(t *testing.T) { tmpDir := t.TempDir() templateFile := filepath.Join(tmpDir, "collection.json") templateContent := fmt.Sprintf(`{ - "workspace_id": "6716028cd934ced9bac18658", + "workspace_id": "%s", "name": "test-collection", + "task_details": %s, "collection_items": %s -}`, collectionItems) +}`, testWorkspaceID, taskDetails, collectionItems) err := os.WriteFile(templateFile, []byte(templateContent), 0600) if err != nil { @@ -388,9 +396,9 @@ func TestNewCreateCollectionCommandRequiresName(t *testing.T) { tmpDir := t.TempDir() templateFile := filepath.Join(tmpDir, "collection.json") templateContent := fmt.Sprintf(`{ - "workspace_id": "6716028cd934ced9bac18658", + "workspace_id": "%s", "collection_items": %s -}`, collectionItems) +}`, testWorkspaceID, collectionItems) err := os.WriteFile(templateFile, []byte(templateContent), 0600) if err != nil { @@ -451,7 +459,7 @@ func TestNewCreateCollectionCommandRequiresItems(t *testing.T) { templateFile := filepath.Join(tmpDir, "collection.json") templateContent := `{ "name": "test-collection", - "workspace_id": "6716028cd934ced9bac18658", + "workspace_id": "` + testWorkspaceID + `", "collection_items": [] }` @@ -527,10 +535,11 @@ func TestNewCreateCollectionCommandWithContentBlocks(t *testing.T) { tmpDir := t.TempDir() templateFile := filepath.Join(tmpDir, "collection.json") templateContent := fmt.Sprintf(`{ - "workspace_id": "6716028cd934ced9bac18658", + "workspace_id": "%s", "name": "test-collection-with-content-blocks", + "task_details": %s, "collection_items": %s -}`, collectionItemsWithContentBlocks) +}`, testWorkspaceID, taskDetails, collectionItemsWithContentBlocks) err := os.WriteFile(templateFile, []byte(templateContent), 0600) if err != nil { @@ -540,7 +549,7 @@ func TestNewCreateCollectionCommandWithContentBlocks(t *testing.T) { response := client.CreateAITaskBuilderCollectionResponse{ ID: "collection-content-blocks-123", Name: "test-collection-with-content-blocks", - WorkspaceID: "6716028cd934ced9bac18658", + WorkspaceID: testWorkspaceID, SchemaVersion: 1, CreatedBy: "user-456", CollectionItems: []model.CollectionPage{ @@ -651,7 +660,7 @@ func TestNewCreateCollectionCommandWithTaskDetails(t *testing.T) { tmpDir := t.TempDir() templateFile := filepath.Join(tmpDir, "collection.json") templateContent := fmt.Sprintf(`{ - "workspace_id": "6716028cd934ced9bac18658", + "workspace_id": "%s", "name": "test-collection-with-task-details", "task_details": { "task_name": "Quality Assessment Task", @@ -659,7 +668,7 @@ func TestNewCreateCollectionCommandWithTaskDetails(t *testing.T) { "task_steps": "1. Read the content on each page\n2. Answer the questions thoughtfully\n3. Submit your responses" }, "collection_items": %s -}`, collectionItems) +}`, testWorkspaceID, collectionItems) err := os.WriteFile(templateFile, []byte(templateContent), 0600) if err != nil { @@ -669,7 +678,7 @@ func TestNewCreateCollectionCommandWithTaskDetails(t *testing.T) { response := client.CreateAITaskBuilderCollectionResponse{ ID: "collection-task-details-123", Name: "test-collection-with-task-details", - WorkspaceID: "6716028cd934ced9bac18658", + WorkspaceID: testWorkspaceID, SchemaVersion: 1, CreatedBy: "user-456", TaskDetails: &model.TaskDetails{ @@ -719,3 +728,143 @@ func TestNewCreateCollectionCommandWithTaskDetails(t *testing.T) { t.Fatalf("expected task_steps to be set; got '%s'", capturedPayload.TaskDetails.TaskSteps) } } + +func TestNewCreateCollectionCommandRequiresTaskDetails(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + tmpDir := t.TempDir() + templateFile := filepath.Join(tmpDir, "collection.json") + templateContent := fmt.Sprintf(`{ + "workspace_id": "%s", + "name": "test-collection", + "collection_items": %s +}`, testWorkspaceID, collectionItems) + + err := os.WriteFile(templateFile, []byte(templateContent), 0600) + if err != nil { + t.Fatalf("failed to create temporary file: %s", err.Error()) + } + + cmd := collection.NewCreateCollectionCommand(c, os.Stdout) + cmd.SetArgs([]string{"-t", templateFile}) + err = cmd.Execute() + + if err == nil { + t.Fatal("expected error when task_details is missing") + } + + expected := collection.ErrTaskDetailsRequired + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error to contain '%s', got '%s'", expected, err.Error()) + } +} + +func TestNewCreateCollectionCommandRequiresTaskName(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + tmpDir := t.TempDir() + templateFile := filepath.Join(tmpDir, "collection.json") + templateContent := fmt.Sprintf(`{ + "workspace_id": "%s", + "name": "test-collection", + "task_details": { + "task_introduction": "Introduction", + "task_steps": "Steps" + }, + "collection_items": %s +}`, testWorkspaceID, collectionItems) + + err := os.WriteFile(templateFile, []byte(templateContent), 0600) + if err != nil { + t.Fatalf("failed to create temporary file: %s", err.Error()) + } + + cmd := collection.NewCreateCollectionCommand(c, os.Stdout) + cmd.SetArgs([]string{"-t", templateFile}) + err = cmd.Execute() + + if err == nil { + t.Fatal("expected error when task_name is missing") + } + + expected := collection.ErrTaskNameRequired + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error to contain '%s', got '%s'", expected, err.Error()) + } +} + +func TestNewCreateCollectionCommandRequiresTaskIntroduction(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + tmpDir := t.TempDir() + templateFile := filepath.Join(tmpDir, "collection.json") + templateContent := fmt.Sprintf(`{ + "workspace_id": "%s", + "name": "test-collection", + "task_details": { + "task_name": "Task Name", + "task_steps": "Steps" + }, + "collection_items": %s +}`, testWorkspaceID, collectionItems) + + err := os.WriteFile(templateFile, []byte(templateContent), 0600) + if err != nil { + t.Fatalf("failed to create temporary file: %s", err.Error()) + } + + cmd := collection.NewCreateCollectionCommand(c, os.Stdout) + cmd.SetArgs([]string{"-t", templateFile}) + err = cmd.Execute() + + if err == nil { + t.Fatal("expected error when task_introduction is missing") + } + + expected := collection.ErrTaskIntroductionRequired + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error to contain '%s', got '%s'", expected, err.Error()) + } +} + +func TestNewCreateCollectionCommandRequiresTaskSteps(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + tmpDir := t.TempDir() + templateFile := filepath.Join(tmpDir, "collection.json") + templateContent := fmt.Sprintf(`{ + "workspace_id": "%s", + "name": "test-collection", + "task_details": { + "task_name": "Task Name", + "task_introduction": "Introduction" + }, + "collection_items": %s +}`, testWorkspaceID, collectionItems) + + err := os.WriteFile(templateFile, []byte(templateContent), 0600) + if err != nil { + t.Fatalf("failed to create temporary file: %s", err.Error()) + } + + cmd := collection.NewCreateCollectionCommand(c, os.Stdout) + cmd.SetArgs([]string{"-t", templateFile}) + err = cmd.Execute() + + if err == nil { + t.Fatal("expected error when task_steps is missing") + } + + expected := collection.ErrTaskStepsRequired + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error to contain '%s', got '%s'", expected, err.Error()) + } +} diff --git a/cmd/collection/list_test.go b/cmd/collection/list_test.go index cc61614..7be000a 100644 --- a/cmd/collection/list_test.go +++ b/cmd/collection/list_test.go @@ -11,7 +11,7 @@ import ( "github.com/prolific-oss/cli/model" ) -const testWorkspaceID = "6655b8281cc82a88996f0bbb" +const testWorkspaceID = "67890abcdef1234567890123" func TestNewListCommand(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/docs/examples/collection.json b/docs/examples/collection.json index cc042ce..155b4d8 100644 --- a/docs/examples/collection.json +++ b/docs/examples/collection.json @@ -30,6 +30,40 @@ } ], "answer_limit": -1 + }, + { + "order": 2, + "type": "multiple_choice_with_unit", + "description": "What is your height?", + "options": [ + { + "label": "150", + "value": "150" + }, + { + "label": "160", + "value": "160" + }, + { + "label": "170", + "value": "170" + }, + { + "label": "180", + "value": "180" + } + ], + "unit_options": [ + { + "label": "CM", + "value": "cm" + }, + { + "label": "Inches", + "value": "in" + } + ], + "default_unit": "cm" } ] } diff --git a/docs/examples/collection.yaml b/docs/examples/collection.yaml index 38a12a7..e1a807c 100644 --- a/docs/examples/collection.yaml +++ b/docs/examples/collection.yaml @@ -19,3 +19,21 @@ collection_items: - label: Response 2 value: response2 answer_limit: -1 + - order: 2 + type: multiple_choice_with_unit + description: What is your height? + options: + - label: "150" + value: "150" + - label: "160" + value: "160" + - label: "170" + value: "170" + - label: "180" + value: "180" + unit_options: + - label: CM + value: cm + - label: Inches + value: in + default_unit: cm diff --git a/docs/examples/test-collection.yaml b/docs/examples/test-collection.yaml new file mode 100644 index 0000000..768cd5b --- /dev/null +++ b/docs/examples/test-collection.yaml @@ -0,0 +1,147 @@ +workspace_id: 679271425fe00981084a5f58 +name: Tell us about your injury +task_details: + task_name: Injury Assessment Survey + task_introduction: "

Please help us by providing information about your injury. Your responses will help improve our understanding of various injury types.

" + task_steps: "
  1. Provide your physical measurements
  2. Identify your skin tone using the reference scale
  3. Describe your injury location and type
  4. Answer all questions accurately
" +collection_items: + - order: 0 + page_items: + - order: 0 + type: multiple_choice_with_unit + description: What is your height? + options: + - label: "150" + value: "150" + - label: "155" + value: "155" + - label: "160" + value: "160" + - label: "165" + value: "165" + - label: "170" + value: "170" + - label: "175" + value: "175" + - label: "180" + value: "180" + - label: "185" + value: "185" + - label: "190" + value: "190" + unit_options: + - label: CM + value: cm + - label: Inches + value: in + default_unit: cm + - order: 1 + type: multiple_choice_with_unit + description: What is your weight? + options: + - label: "40" + value: "40" + - label: "45" + value: "45" + - label: "50" + value: "50" + - label: "55" + value: "55" + - label: "60" + value: "60" + - label: "65" + value: "65" + - label: "70" + value: "70" + - label: "75" + value: "75" + - label: "80" + value: "80" + - label: "85" + value: "85" + - label: "90" + value: "90" + unit_options: + - label: KG + value: kg + - label: lbs + value: lbs + default_unit: kg + - order: 2 + type: image + url: https://i.postimg.cc/Bv5PKTM3/Screenshot-2025-12-01-at-14-11-02.png + alt_text: Skin tone scale showing colors numbered 1 through 10, from lightest to darkest + caption: Skin tone reference scale + - order: 3 + type: multiple_choice + description: Look at the healthy skin next to your injury (or where the injury used to be). Using the scale above, pick the number (1-10) that most closely matches your skin. + options: + - label: "1" + value: "1" + - label: "2" + value: "2" + - label: "3" + value: "3" + - label: "4" + value: "4" + - label: "5" + value: "5" + - label: "6" + value: "6" + - label: "7" + value: "7" + - label: "8" + value: "8" + - label: "9" + value: "9" + - label: "10" + value: "10" + answer_limit: 1 + - order: 4 + type: multiple_choice + description: Where on your body is the injury? + options: + - label: Head + value: head + - label: Face + value: face + - label: Neck + value: neck + - label: Chest + value: chest + - label: Back + value: back + - label: Abdomen + value: abdomen + - label: Left Arm + value: left_arm + - label: Right Arm + value: right_arm + - label: Left Hand + value: left_hand + - label: Right Hand + value: right_hand + - label: Left Leg + value: left_leg + - label: Right Leg + value: right_leg + - label: Left Foot + value: left_foot + - label: Right Foot + value: right_foot + answer_limit: 1 + - order: 5 + type: multiple_choice + description: "At any point, did the injury break the skin? (For example: a cut, scrape, or scab)" + options: + - label: "Yes" + value: "yes" + - label: "No" + value: "no" + answer_limit: 1 + - order: 6 + type: free_text + description: "What type of injury do you think this is? Briefly describe in your own words. (For example: bruise, cut, scrape, scratch, scar, surgical wound, burn, etc.)" + - order: 7 + type: free_text + description: "What caused the injury? Briefly describe in your own words. (For example: fell, hit a wall, cut with a knife, dog bite, human bite, etc.)" diff --git a/model/ai_task_builder.go b/model/ai_task_builder.go index 04eb663..04d8b1a 100644 --- a/model/ai_task_builder.go +++ b/model/ai_task_builder.go @@ -73,6 +73,8 @@ type Instruction struct { CreatedAt string `json:"created_at"` Description string `json:"description"` Options []InstructionOption `json:"options,omitempty"` + UnitOptions []UnitOption `json:"unit_options,omitempty"` + DefaultUnit string `json:"default_unit,omitempty"` } // AITaskBuilderResponse represents a response from an AI Task Builder batch task. @@ -95,7 +97,8 @@ type AITaskBuilderResponseData struct { InstructionID string `json:"instruction_id"` Type AITaskBuilderResponseType `json:"type"` Text *string `json:"text,omitempty"` // For free_text and multiple_choice_with_free_text - Answer []AITaskBuilderAnswerOption `json:"answer,omitempty"` // For multiple_choice and multiple_choice_with_free_text + Answer []AITaskBuilderAnswerOption `json:"answer,omitempty"` // For multiple_choice, multiple_choice_with_free_text, and multiple_choice_with_unit + Unit *string `json:"unit,omitempty"` // For multiple_choice_with_unit - the selected unit value } // AITaskBuilderResponseType represents the type of response. @@ -105,6 +108,7 @@ const ( AITaskBuilderResponseTypeFreeText AITaskBuilderResponseType = "free_text" AITaskBuilderResponseTypeMultipleChoice AITaskBuilderResponseType = "multiple_choice" AITaskBuilderResponseTypeMultipleChoiceWithFreeText AITaskBuilderResponseType = "multiple_choice_with_free_text" + AITaskBuilderResponseTypeMultipleChoiceWithUnit AITaskBuilderResponseType = "multiple_choice_with_unit" ) // AITaskBuilderAnswerOption represents an answer option for multiple choice responses. @@ -145,6 +149,7 @@ const ( PageItemTypeFreeText = "free_text" PageItemTypeMultipleChoice = "multiple_choice" PageItemTypeMultipleChoiceWithFreeText = "multiple_choice_with_free_text" + PageItemTypeMultipleChoiceWithUnit = "multiple_choice_with_unit" PageItemTypeFileUpload = "file_upload" // Content block types (non-interactive) @@ -159,13 +164,17 @@ type CollectionPageItem struct { Order int `json:"order" mapstructure:"order"` Type string `json:"type" mapstructure:"type"` - // Instruction fields (for free_text, multiple_choice, multiple_choice_with_free_text, file_upload) + // Instruction fields (for free_text, multiple_choice, multiple_choice_with_free_text, multiple_choice_with_unit, file_upload) Description string `json:"description,omitempty" mapstructure:"description"` Options []InstructionOption `json:"options,omitempty" mapstructure:"options"` AnswerLimit *int `json:"answer_limit,omitempty" mapstructure:"answer_limit"` PlaceholderTextInput string `json:"placeholder_text_input,omitempty" mapstructure:"placeholder_text_input"` DisableDropdown *bool `json:"disable_dropdown,omitempty" mapstructure:"disable_dropdown"` + // Unit fields (for multiple_choice_with_unit) + UnitOptions []UnitOption `json:"unit_options,omitempty" mapstructure:"unit_options"` + DefaultUnit string `json:"default_unit,omitempty" mapstructure:"default_unit"` + // Content block fields (for rich_text) Content string `json:"content,omitempty" mapstructure:"content"` diff --git a/model/collection.go b/model/collection.go index 0798bb6..8824425 100644 --- a/model/collection.go +++ b/model/collection.go @@ -44,6 +44,7 @@ const ( InstructionTypeFreeText InstructionType = "free_text" InstructionTypeMultipleChoice InstructionType = "multiple_choice" InstructionTypeMultipleChoiceWithFreeText InstructionType = "multiple_choice_with_free_text" + InstructionTypeMultipleChoiceWithUnit InstructionType = "multiple_choice_with_unit" // Content block types (non-interactive - for context or guidance) ContentBlockTypeRichText InstructionType = "rich_text" @@ -57,6 +58,12 @@ type MultipleChoiceOption struct { Heading string `json:"heading,omitempty" yaml:"heading,omitempty" mapstructure:"heading"` // Required for multiple_choice_with_free_text } +// UnitOption represents a unit option for multiple_choice_with_unit instructions +type UnitOption struct { + Label string `json:"label" yaml:"label" mapstructure:"label"` + Value string `json:"value" yaml:"value" mapstructure:"value"` +} + // PageInstruction represents a single page item within a collection page. // This can be either an instruction (interactive) or a content block (non-interactive). type PageInstruction struct { @@ -66,7 +73,7 @@ type PageInstruction struct { Type InstructionType `json:"type" yaml:"type" mapstructure:"type"` Order int `json:"order" yaml:"order" mapstructure:"order"` - // Required for instruction types (free_text, multiple_choice, multiple_choice_with_free_text, file_upload) + // Required for instruction types (free_text, multiple_choice, multiple_choice_with_free_text, multiple_choice_with_unit, file_upload) Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description"` // Optional - for free_text type @@ -77,6 +84,10 @@ type PageInstruction struct { Options []MultipleChoiceOption `json:"options,omitempty" yaml:"options,omitempty" mapstructure:"options"` DisableDropdown *bool `json:"disable_dropdown,omitempty" yaml:"disable_dropdown,omitempty" mapstructure:"disable_dropdown"` + // Optional - for multiple_choice_with_unit type + UnitOptions []UnitOption `json:"unit_options,omitempty" yaml:"unit_options,omitempty" mapstructure:"unit_options"` + DefaultUnit string `json:"default_unit,omitempty" yaml:"default_unit,omitempty" mapstructure:"default_unit"` + // Content block fields - for rich_text type Content string `json:"content,omitempty" yaml:"content,omitempty" mapstructure:"content"`