diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3432d..6028a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ +### AI Task Builder + +- Support the `star_rating` instruction type (participants pick a rating from 1 to `max_stars`, where `max_stars` is 1-10 and defaults to 5) + ## 1.1.0 ### AI Task Builder diff --git a/client/payloads.go b/client/payloads.go index 50b304a..1366a9f 100644 --- a/client/payloads.go +++ b/client/payloads.go @@ -161,6 +161,8 @@ const ( InstructionTypeFreeTextWithUnit InstructionType = "free_text_with_unit" // InstructionTypeFileUpload represents a file upload instruction. InstructionTypeFileUpload InstructionType = "file_upload" + // InstructionTypeStarRating represents a star rating instruction. + InstructionTypeStarRating InstructionType = "star_rating" ) // InstructionOption represents an option for multiple choice instructions @@ -202,6 +204,7 @@ type Instruction struct { MaxFileSizeMB *float64 `json:"max_file_size_mb,omitempty"` MinFileCount *int `json:"min_file_count,omitempty"` MaxFileCount *int `json:"max_file_count,omitempty"` + MaxStars *int `json:"max_stars,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 c01965f..0d52599 100644 --- a/cmd/aitaskbuilder/batch_instructions.go +++ b/cmd/aitaskbuilder/batch_instructions.go @@ -38,7 +38,8 @@ The instructions should be an array of instruction objects with the following ty - free_text: Instructions requiring text input - multiple_choice_with_free_text: Instructions with options and text input - free_text_with_unit: Instructions requiring text input with unit selection (e.g., weight with kg/lbs) -- file_upload: Instructions for uploading files (e.g., images, documents)`, +- file_upload: Instructions for uploading files (e.g., images, documents) +- star_rating: Instructions where participants pick a star rating from 1 to max_stars (max_stars is 1-10, defaults to 5)`, Example: ` Add instructions from a file: $ prolific aitaskbuilder batch instructions -b -f instructions.json @@ -156,6 +157,7 @@ func validateInstructions(instructions client.CreateAITaskBuilderInstructionsPay client.InstructionTypeMultipleChoiceWithFreeText: true, client.InstructionTypeFreeTextWithUnit: true, client.InstructionTypeFileUpload: true, + client.InstructionTypeStarRating: true, } for i, instruction := range instructions.Instructions { @@ -182,7 +184,7 @@ func validateInstructionBasicFields(instruction client.Instruction, index int, v } 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, free_text_with_unit, file_upload", index+1, instruction.Type) + return fmt.Errorf("instruction %d: invalid type '%s'. Must be one of: multiple_choice, free_text, multiple_choice_with_free_text, free_text_with_unit, file_upload, star_rating", index+1, instruction.Type) } if instruction.CreatedBy == "" { @@ -216,6 +218,21 @@ func validateInstructionTypeSpecificFields(instruction client.Instruction, index return validateFileUpload(instruction, index) } + // Validate star rating fields for star_rating + if instruction.Type == client.InstructionTypeStarRating { + return validateStarRating(instruction, index) + } + + return nil +} + +// validateStarRating validates star_rating specific fields. max_stars is optional +// (the API defaults it to 5); when provided it must be between 1 and 10. +func validateStarRating(instruction client.Instruction, index int) error { + if instruction.MaxStars != nil && (*instruction.MaxStars < 1 || *instruction.MaxStars > 10) { + return fmt.Errorf("instruction %d: max_stars must be between 1 and 10, got %d", index+1, *instruction.MaxStars) + } + return nil } diff --git a/cmd/aitaskbuilder/batch_instructions_starrating_test.go b/cmd/aitaskbuilder/batch_instructions_starrating_test.go new file mode 100644 index 0000000..fa9eb51 --- /dev/null +++ b/cmd/aitaskbuilder/batch_instructions_starrating_test.go @@ -0,0 +1,129 @@ +package aitaskbuilder_test + +import ( + "bufio" + "bytes" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/aitaskbuilder" + "github.com/prolific-oss/cli/mock_client" + "github.com/prolific-oss/cli/model" +) + +func TestNewBatchInstructionsCommandWithStarRating(t *testing.T) { + fiveStars := 5 + + testCases := []struct { + name string + instructionsJSON string + payloadMaxStars *int + }{ + { + name: "with explicit max_stars", + instructionsJSON: `[{ + "type": "star_rating", + "created_by": "Sean", + "description": "How would you rate the overall quality of this response?", + "max_stars": 5 + }]`, + payloadMaxStars: &fiveStars, + }, + { + // max_stars is optional - the API defaults it to 5 when omitted. + name: "without max_stars", + instructionsJSON: `[{ + "type": "star_rating", + "created_by": "Sean", + "description": "How would you rate the overall quality of this response?" + }]`, + payloadMaxStars: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e12360" + + expectedPayload := client.CreateAITaskBuilderInstructionsPayload{ + Instructions: []client.Instruction{ + { + Type: "star_rating", + CreatedBy: "Sean", + Description: "How would you rate the overall quality of this response?", + MaxStars: tc.payloadMaxStars, + }, + }, + } + + response := client.CreateAITaskBuilderInstructionsResponse{ + model.Instruction{ + ID: "inst-star-1", + Type: "star_rating", + BatchID: batchID, + CreatedBy: "Sean", + Description: "How would you rate the overall quality of this response?", + MaxStars: tc.payloadMaxStars, + }, + } + + c.EXPECT().CreateAITaskBuilderInstructions(batchID, expectedPayload).Return(&response, nil) + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + cmd.SetArgs([]string{"-b", batchID, "-j", tc.instructionsJSON}) + + if err := cmd.Execute(); 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()) + } + }) + } +} + +func TestNewBatchInstructionsCommandStarRatingInvalidMaxStars(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "01954894-65b3-779e-aaf6-348698e12362" + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + + cmd := aitaskbuilder.NewBatchInstructionsCommand(c, writer) + + instructionsJSON := `[{ + "type": "star_rating", + "created_by": "Sean", + "description": "Rate this response", + "max_stars": 11 + }]` + + cmd.SetArgs([]string{ + "-b", batchID, + "-j", instructionsJSON, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error for max_stars out of range; got none") + } + + if !strings.Contains(err.Error(), "max_stars must be between 1 and 10") { + t.Fatalf("expected error about max_stars range; got %s", err.Error()) + } +} diff --git a/cmd/aitaskbuilder/get_task_responses.go b/cmd/aitaskbuilder/get_task_responses.go index 1962060..559c587 100644 --- a/cmd/aitaskbuilder/get_task_responses.go +++ b/cmd/aitaskbuilder/get_task_responses.go @@ -155,6 +155,12 @@ func renderAITaskBuilderResponses(c client.API, opts BatchGetResponsesOptions, w } else { fmt.Fprintf(w, " Uploaded Files: \n") } + case model.AITaskBuilderResponseTypeStarRating: + if len(resp.Response.Answer) > 0 && resp.Response.Answer[0].Value != "" { + fmt.Fprintf(w, " Star Rating: %s\n", resp.Response.Answer[0].Value) + } else { + fmt.Fprintf(w, " Star Rating: \n") + } } if i < len(response.Results)-1 { diff --git a/docs/examples/batch-instructions.json b/docs/examples/batch-instructions.json index f6cea09..53f2d6b 100644 --- a/docs/examples/batch-instructions.json +++ b/docs/examples/batch-instructions.json @@ -128,5 +128,11 @@ "max_file_size_mb": 10.0, "min_file_count": 2, "max_file_count": 5 + }, + { + "type": "star_rating", + "created_by": "Sean", + "description": "How would you rate the overall quality of this response?", + "max_stars": 5 } ] diff --git a/model/ai_task_builder.go b/model/ai_task_builder.go index 105a1d7..0f22fdf 100644 --- a/model/ai_task_builder.go +++ b/model/ai_task_builder.go @@ -153,6 +153,7 @@ type Instruction struct { MaxFileSizeMB *float64 `json:"max_file_size_mb,omitempty"` MinFileCount *int `json:"min_file_count,omitempty"` MaxFileCount *int `json:"max_file_count,omitempty"` + MaxStars *int `json:"max_stars,omitempty"` } // AITaskBuilderResponse represents a response from an AI Task Builder batch task. @@ -187,6 +188,7 @@ const ( AITaskBuilderResponseTypeMultipleChoiceWithFreeText AITaskBuilderResponseType = "multiple_choice_with_free_text" AITaskBuilderResponseTypeFreeTextWithUnit AITaskBuilderResponseType = "free_text_with_unit" AITaskBuilderResponseTypeFileUpload AITaskBuilderResponseType = "file_upload" + AITaskBuilderResponseTypeStarRating AITaskBuilderResponseType = "star_rating" ) // AITaskBuilderAnswerOption represents an answer option in a response. @@ -196,8 +198,9 @@ const ( // - multiple_choice: value // - multiple_choice_with_free_text: value, explanation // - file_upload: file_key, file_name, file_size_mb, content_type +// - star_rating: value (the selected rating, e.g. "3") type AITaskBuilderAnswerOption struct { - // For free_text, free_text_with_unit, multiple_choice, multiple_choice_with_free_text + // For free_text, free_text_with_unit, multiple_choice, multiple_choice_with_free_text, star_rating Value string `json:"value,omitempty"` // For free_text_with_unit Unit string `json:"unit,omitempty"` @@ -245,7 +248,7 @@ 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, free_text_with_unit, file_upload) + // Instruction fields (for free_text, multiple_choice, multiple_choice_with_free_text, free_text_with_unit, file_upload, star_rating) Description string `json:"description,omitempty" mapstructure:"description"` Options []InstructionOption `json:"options,omitempty" mapstructure:"options"` AnswerLimit *int `json:"answer_limit,omitempty" mapstructure:"answer_limit"` @@ -265,6 +268,9 @@ type CollectionPageItem struct { MinFileCount *int `json:"min_file_count,omitempty" mapstructure:"min_file_count"` MaxFileCount *int `json:"max_file_count,omitempty" mapstructure:"max_file_count"` + // Star rating fields (for star_rating) + MaxStars *int `json:"max_stars,omitempty" mapstructure:"max_stars"` + // Content block fields (for rich_text) Content string `json:"content,omitempty" mapstructure:"content"` ContentFormat ContentFormat `json:"content_format,omitempty" mapstructure:"content_format"` diff --git a/model/collection.go b/model/collection.go index 80ed197..c451c06 100644 --- a/model/collection.go +++ b/model/collection.go @@ -46,6 +46,7 @@ const ( InstructionTypeMultipleChoiceWithFreeText InstructionType = "multiple_choice_with_free_text" InstructionTypeFreeTextWithUnit InstructionType = "free_text_with_unit" InstructionTypeFileUpload InstructionType = "file_upload" + InstructionTypeStarRating InstructionType = "star_rating" // Content block types (non-interactive - for context or guidance) ContentBlockTypeRichText InstructionType = "rich_text" @@ -93,7 +94,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, free_text_with_unit, file_upload) + // Required for instruction types (free_text, multiple_choice, multiple_choice_with_free_text, free_text_with_unit, file_upload, star_rating) Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description"` // Optional - for free_text and free_text_with_unit types @@ -117,6 +118,9 @@ type PageInstruction struct { MinFileCount *int `json:"min_file_count,omitempty" yaml:"min_file_count,omitempty" mapstructure:"min_file_count"` MaxFileCount *int `json:"max_file_count,omitempty" yaml:"max_file_count,omitempty" mapstructure:"max_file_count"` + // Optional - for star_rating type (number of stars, 1-10, defaults to 5) + MaxStars *int `json:"max_stars,omitempty" yaml:"max_stars,omitempty" mapstructure:"max_stars"` + // Content block fields - for rich_text type Content string `json:"content,omitempty" yaml:"content,omitempty" mapstructure:"content"` ContentFormat ContentFormat `json:"content_format,omitempty" yaml:"content_format,omitempty" mapstructure:"content_format"`