From 5ddb51fcc52bf94c60df7eae9a746d634376e89e Mon Sep 17 00:00:00 2001 From: Desy Kristianti Date: Tue, 21 Jul 2026 19:40:56 +0100 Subject: [PATCH 1/3] feat(DCT-91): map video fields Adds video_url as a supported AI Task Builder dataset schema field type, mirroring the existing audio_url support, and validates video URL extensions on dataset upload. Also moves the manual batch-preview test scripts into per-script subdirectories so multiple package main scripts can coexist under scripts/manual-tests, and adds a video equivalent of the audio preview script. Co-Authored-By: Claude Sonnet 5 --- cmd/aitaskbuilder/create_dataset.go | 5 +- cmd/aitaskbuilder/dataset_schema.go | 3 +- cmd/aitaskbuilder/dataset_schema_test.go | 10 +- cmd/aitaskbuilder/upload_dataset.go | 96 ++++++--- cmd/aitaskbuilder/upload_dataset_test.go | 147 +++++++++----- docs/examples/dataset-schema.json | 1 + .../main.go} | 2 +- .../manual-tests/video_batch_preview/main.go | 192 ++++++++++++++++++ 8 files changed, 378 insertions(+), 78 deletions(-) rename scripts/manual-tests/{test_audio_batch_preview.go => audio_batch_preview/main.go} (99%) create mode 100644 scripts/manual-tests/video_batch_preview/main.go diff --git a/cmd/aitaskbuilder/create_dataset.go b/cmd/aitaskbuilder/create_dataset.go index cc6f65e..ae38ce4 100644 --- a/cmd/aitaskbuilder/create_dataset.go +++ b/cmd/aitaskbuilder/create_dataset.go @@ -45,13 +45,14 @@ passes it through unchanged. The value is the full schema object, for example: "question": { "type": "text", "label": "Question" }, "image": { "type": "image_url" }, "audio": { "type": "audio_url" }, + "video": { "type": "video_url" }, "source": { "type": "metadata" }, "group": { "type": "task_group_id" } } } -Field types are text, image_url, audio_url, metadata, and task_group_id (at -most one). By default schemas are created with "strict": false. Use --strict +Field types are text, image_url, audio_url, video_url, metadata, and +task_group_id (at most one). By default schemas are created with "strict": false. Use --strict to enable strict mode when the schema JSON does not already set "strict" (passing --strict alongside a schema that sets "strict" is an error). See docs/examples/dataset-schema.json for a full example. diff --git a/cmd/aitaskbuilder/dataset_schema.go b/cmd/aitaskbuilder/dataset_schema.go index 115a72a..3b2d7ce 100644 --- a/cmd/aitaskbuilder/dataset_schema.go +++ b/cmd/aitaskbuilder/dataset_schema.go @@ -19,6 +19,7 @@ var validDatasetSchemaFieldTypes = map[string]bool{ "metadata": true, "task_group_id": true, "audio_url": true, + "video_url": true, } // rawDatasetSchema mirrors DatasetSchema but distinguishes an absent "strict" @@ -55,7 +56,7 @@ func resolveDatasetSchema(schemaInput string, strict, strictSet bool) (*client.D taskGroupIDCount := 0 for key, field := range parsed.Fields { if !validDatasetSchemaFieldTypes[field.Type] { - return nil, fmt.Errorf("field %q has invalid type %q; must be one of text, image_url, metadata, task_group_id, audio_url", key, field.Type) + return nil, fmt.Errorf("field %q has invalid type %q; must be one of text, image_url, metadata, task_group_id, audio_url, video_url", key, field.Type) } if field.Type == "task_group_id" { taskGroupIDCount++ diff --git a/cmd/aitaskbuilder/dataset_schema_test.go b/cmd/aitaskbuilder/dataset_schema_test.go index 9934ede..1b1fd6d 100644 --- a/cmd/aitaskbuilder/dataset_schema_test.go +++ b/cmd/aitaskbuilder/dataset_schema_test.go @@ -19,6 +19,7 @@ func TestResolveDatasetSchemaInlineValid(t *testing.T) { "question": { "type": "text", "label": "Question" }, "image": { "type": "image_url" }, "audio": { "type": "audio_url" }, + "video": { "type": "video_url" }, "source": { "type": "metadata" }, "group": { "type": "task_group_id" } } @@ -34,12 +35,15 @@ func TestResolveDatasetSchemaInlineValid(t *testing.T) { if schema.Strict == nil || !*schema.Strict { t.Fatal("expected strict to be true from JSON") } - if len(schema.Fields) != 5 { - t.Fatalf("expected 5 fields; got %d", len(schema.Fields)) + if len(schema.Fields) != 6 { + t.Fatalf("expected 6 fields; got %d", len(schema.Fields)) } if schema.Fields["audio"].Type != "audio_url" { t.Fatalf("unexpected audio field: %+v", schema.Fields["audio"]) } + if schema.Fields["video"].Type != "video_url" { + t.Fatalf("unexpected video field: %+v", schema.Fields["video"]) + } if schema.Fields["question"].Type != "text" || schema.Fields["question"].Label != "Question" { t.Fatalf("unexpected question field: %+v", schema.Fields["question"]) } @@ -158,7 +162,7 @@ func TestResolveDatasetSchemaInvalidFieldType(t *testing.T) { if !strings.Contains(msg, `"q"`) || !strings.Contains(msg, `"number"`) { t.Fatalf("expected error to name field and type; got %v", err) } - if !strings.Contains(msg, "text, image_url, metadata, task_group_id, audio_url") { + if !strings.Contains(msg, "text, image_url, metadata, task_group_id, audio_url, video_url") { t.Fatalf("expected error to list allowed types; got %v", err) } } diff --git a/cmd/aitaskbuilder/upload_dataset.go b/cmd/aitaskbuilder/upload_dataset.go index 2477a56..00cc407 100644 --- a/cmd/aitaskbuilder/upload_dataset.go +++ b/cmd/aitaskbuilder/upload_dataset.go @@ -40,6 +40,15 @@ var validAudioURLFileExtensions = map[string]bool{ const supportedAudioURLFileExtensions = ".aac, .m4a, .mp3, .wav" +var validVideoURLFileExtensions = map[string]bool{ + ".mp4": true, + ".mov": true, + ".webm": true, + ".avi": true, +} + +const supportedVideoURLFileExtensions = ".mp4, .mov, .webm, .avi" + // DatasetUploadOptions are the options for uploading to an AI Task Builder dataset. type DatasetUploadOptions struct { Args []string @@ -132,6 +141,10 @@ func uploadDatasetFile(client client.API, opts DatasetUploadOptions, w io.Writer return err } + if err := validateVideoURLFields(opts.FilePath, uploadRequest.Format, dataset.Schema); err != nil { + return err + } + fmt.Fprintf(w, "Getting upload URL for dataset %s and file %s...\n", opts.DatasetID, uploadRequest.UploadFilename) // Get upload URL from API @@ -280,32 +293,53 @@ func uploadFileToPresignedURL(filePath, uploadURL, method, contentType string) e } func validateAudioURLFields(filePath string, format model.DatasetImportFormat, schema *client.DatasetSchema) error { + return validateMediaURLFields(filePath, format, schema, "audio_url", "audio", validAudioURLFileExtensions, supportedAudioURLFileExtensions) +} + +func validateVideoURLFields(filePath string, format model.DatasetImportFormat, schema *client.DatasetSchema) error { + return validateMediaURLFields(filePath, format, schema, "video_url", "video", validVideoURLFileExtensions, supportedVideoURLFileExtensions) +} + +func validateMediaURLFields( + filePath string, + format model.DatasetImportFormat, + schema *client.DatasetSchema, + fieldType, mediaLabel string, + extensions map[string]bool, + supportedExtensions string, +) error { if schema == nil { return nil } - audioFields := make(map[string]struct{}) + mediaFields := make(map[string]struct{}) for fieldName, field := range schema.Fields { - if field.Type == "audio_url" { - audioFields[fieldName] = struct{}{} + if field.Type == fieldType { + mediaFields[fieldName] = struct{}{} } } - if len(audioFields) == 0 { + if len(mediaFields) == 0 { return nil } switch format { case model.DatasetImportFormatCSV: - return validateAudioURLFieldsInCSV(filePath, audioFields) + return validateMediaURLFieldsInCSV(filePath, mediaFields, mediaLabel, extensions, supportedExtensions) case model.DatasetImportFormatJSONL: - return ValidateAudioURLFieldsInJSONL(filePath, audioFields) + return validateMediaURLFieldsInJSONL(filePath, mediaFields, mediaLabel, extensions, supportedExtensions) default: return nil } } -func validateAudioURLFieldsInCSV(filePath string, audioFields map[string]struct{}) error { +func validateMediaURLFieldsInCSV( + filePath string, + mediaFields map[string]struct{}, + mediaLabel string, + extensions map[string]bool, + supportedExtensions string, +) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file %s: %w", filePath, err) @@ -318,15 +352,15 @@ func validateAudioURLFieldsInCSV(filePath string, audioFields map[string]struct{ return fmt.Errorf("failed to read CSV header from %s: %w", filePath, err) } - audioColumnIndexes := make(map[int]string) + mediaColumnIndexes := make(map[int]string) for idx, header := range headers { fieldName := strings.TrimSpace(header) - if _, ok := audioFields[fieldName]; ok { - audioColumnIndexes[idx] = fieldName + if _, ok := mediaFields[fieldName]; ok { + mediaColumnIndexes[idx] = fieldName } } - if len(audioColumnIndexes) == 0 { + if len(mediaColumnIndexes) == 0 { return nil } @@ -340,12 +374,12 @@ func validateAudioURLFieldsInCSV(filePath string, audioFields map[string]struct{ return fmt.Errorf("failed to read CSV record %d from %s: %w", recordIndex, filePath, err) } - for idx, fieldName := range audioColumnIndexes { + for idx, fieldName := range mediaColumnIndexes { if idx >= len(record) { continue } - if err := validateAudioURLValue(recordIndex, fieldName, record[idx]); err != nil { + if err := validateMediaURLValue(recordIndex, fieldName, record[idx], mediaLabel, extensions, supportedExtensions); err != nil { return err } } @@ -355,6 +389,20 @@ func validateAudioURLFieldsInCSV(filePath string, audioFields map[string]struct{ } func ValidateAudioURLFieldsInJSONL(filePath string, audioFields map[string]struct{}) error { + return validateMediaURLFieldsInJSONL(filePath, audioFields, "audio", validAudioURLFileExtensions, supportedAudioURLFileExtensions) +} + +func ValidateVideoURLFieldsInJSONL(filePath string, videoFields map[string]struct{}) error { + return validateMediaURLFieldsInJSONL(filePath, videoFields, "video", validVideoURLFileExtensions, supportedVideoURLFileExtensions) +} + +func validateMediaURLFieldsInJSONL( + filePath string, + mediaFields map[string]struct{}, + mediaLabel string, + extensions map[string]bool, + supportedExtensions string, +) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file %s: %w", filePath, err) @@ -377,7 +425,7 @@ func ValidateAudioURLFieldsInJSONL(filePath string, audioFields map[string]struc return fmt.Errorf("failed to parse JSONL record %d from %s: %w", recordIndex, filePath, err) } - for fieldName := range audioFields { + for fieldName := range mediaFields { value, ok := record[fieldName] if !ok || value == nil { continue @@ -386,14 +434,15 @@ func ValidateAudioURLFieldsInJSONL(filePath string, audioFields map[string]struc valueString, ok := value.(string) if !ok { return fmt.Errorf( - "record %d field %s: audio URL must be a string ending with one of %s", + "record %d field %s: %s URL must be a string ending with one of %s", recordIndex, fieldName, - supportedAudioURLFileExtensions, + mediaLabel, + supportedExtensions, ) } - if err := validateAudioURLValue(recordIndex, fieldName, valueString); err != nil { + if err := validateMediaURLValue(recordIndex, fieldName, valueString, mediaLabel, extensions, supportedExtensions); err != nil { return err } } @@ -408,33 +457,34 @@ func ValidateAudioURLFieldsInJSONL(filePath string, audioFields map[string]struc return nil } -func validateAudioURLValue(recordIndex int, fieldName, value string) error { +func validateMediaURLValue(recordIndex int, fieldName, value, mediaLabel string, extensions map[string]bool, supportedExtensions string) error { trimmedValue := strings.TrimSpace(value) if trimmedValue == "" { return nil } - if !hasSupportedAudioURLExtension(trimmedValue) { + if !hasSupportedURLExtension(trimmedValue, extensions) { return fmt.Errorf( - "record %d field %s: audio URL %q must end with one of %s", + "record %d field %s: %s URL %q must end with one of %s", recordIndex, fieldName, + mediaLabel, trimmedValue, - supportedAudioURLFileExtensions, + supportedExtensions, ) } return nil } -func hasSupportedAudioURLExtension(value string) bool { +func hasSupportedURLExtension(value string, extensions map[string]bool) bool { parsedURL, err := url.ParseRequestURI(value) if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { return false } extension := strings.ToLower(filepath.Ext(parsedURL.Path)) - return validAudioURLFileExtensions[extension] + return extensions[extension] } func waitForDatasetImport( diff --git a/cmd/aitaskbuilder/upload_dataset_test.go b/cmd/aitaskbuilder/upload_dataset_test.go index 18e4acd..422d87a 100644 --- a/cmd/aitaskbuilder/upload_dataset_test.go +++ b/cmd/aitaskbuilder/upload_dataset_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -249,57 +250,107 @@ func TestDatasetUploadCommandFormatOverrideAppendsExtension(t *testing.T) { } } -func TestDatasetUploadCommandRejectsUnsupportedCSVAudioExtension(t *testing.T) { - filePath := filepath.Join(t.TempDir(), "dataset.csv") - if err := os.WriteFile(filePath, []byte("question,clip\nhello,https://example.com/audio.txt\n"), 0o600); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - c.EXPECT(). - GetAITaskBuilderDataset(gomock.Eq("dataset-audio")). - Return(&client.GetAITaskBuilderDatasetResponse{ - Schema: &client.DatasetSchema{ - Fields: map[string]client.DatasetSchemaField{ - "clip": {Type: "audio_url"}, - }, - }, - }, nil). - Times(1) - - cmd := aitaskbuilder.NewDatasetUploadCommand(c, os.Stdout) - _ = cmd.Flags().Set("dataset-id", "dataset-audio") - _ = cmd.Flags().Set("file", filePath) - - err := cmd.RunE(cmd, nil) - if err == nil { - t.Fatal("expected invalid audio URL extension error") - } - - if !strings.Contains(err.Error(), `must end with one of .aac, .m4a, .mp3, .wav`) { - t.Fatalf("expected supported extensions in error, got %v", err) +func TestDatasetUploadCommandRejectsUnsupportedCSVMediaExtension(t *testing.T) { + tests := []struct { + name string + fieldType string + datasetID string + unsupportedURL string + supportedExtensions string + }{ + { + name: "audio", + fieldType: "audio_url", + datasetID: "dataset-audio", + unsupportedURL: "https://example.com/audio.txt", + supportedExtensions: ".aac, .m4a, .mp3, .wav", + }, + { + name: "video", + fieldType: "video_url", + datasetID: "dataset-video", + unsupportedURL: "https://example.com/video.txt", + supportedExtensions: ".mp4, .mov, .webm, .avi", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "dataset.csv") + csvContents := fmt.Sprintf("question,clip\nhello,%s\n", tt.unsupportedURL) + if err := os.WriteFile(filePath, []byte(csvContents), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + c.EXPECT(). + GetAITaskBuilderDataset(gomock.Eq(tt.datasetID)). + Return(&client.GetAITaskBuilderDatasetResponse{ + Schema: &client.DatasetSchema{ + Fields: map[string]client.DatasetSchemaField{ + "clip": {Type: tt.fieldType}, + }, + }, + }, nil). + Times(1) + + cmd := aitaskbuilder.NewDatasetUploadCommand(c, os.Stdout) + _ = cmd.Flags().Set("dataset-id", tt.datasetID) + _ = cmd.Flags().Set("file", filePath) + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected invalid media URL extension error") + } + + if !strings.Contains(err.Error(), fmt.Sprintf("must end with one of %s", tt.supportedExtensions)) { + t.Fatalf("expected supported extensions in error, got %v", err) + } + }) } } -func TestValidateAudioURLFieldsInJSONLRejectsUnsupportedAudioExtension(t *testing.T) { - filePath := filepath.Join(t.TempDir(), "dataset.jsonl") - if err := os.WriteFile(filePath, []byte("{\"clip\":\"https://example.com/audio.mov\"}\n"), 0o600); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - audioFields := map[string]struct{}{ - "clip": {}, - } - - err := aitaskbuilder.ValidateAudioURLFieldsInJSONL(filePath, audioFields) - if err == nil { - t.Fatal("expected invalid audio URL extension error") - } - - if !strings.Contains(err.Error(), "record 1 field clip") { - t.Fatalf("expected record location in error, got %v", err) +func TestValidateMediaURLFieldsInJSONLRejectsUnsupportedExtension(t *testing.T) { + tests := []struct { + name string + validate func(string, map[string]struct{}) error + unsupportedURL string + }{ + { + name: "audio", + validate: aitaskbuilder.ValidateAudioURLFieldsInJSONL, + unsupportedURL: "https://example.com/audio.mov", + }, + { + name: "video", + validate: aitaskbuilder.ValidateVideoURLFieldsInJSONL, + unsupportedURL: "https://example.com/video.mp3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "dataset.jsonl") + content := fmt.Sprintf("{\"clip\":%q}\n", tt.unsupportedURL) + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + mediaFields := map[string]struct{}{ + "clip": {}, + } + + err := tt.validate(filePath, mediaFields) + if err == nil { + t.Fatal("expected invalid media URL extension error") + } + + if !strings.Contains(err.Error(), "record 1 field clip") { + t.Fatalf("expected record location in error, got %v", err) + } + }) } } diff --git a/docs/examples/dataset-schema.json b/docs/examples/dataset-schema.json index df374bd..621dab5 100644 --- a/docs/examples/dataset-schema.json +++ b/docs/examples/dataset-schema.json @@ -4,6 +4,7 @@ "question": { "type": "text", "label": "Question" }, "image": { "type": "image_url", "label": "Reference image" }, "audio": { "type": "audio_url", "label": "Reference audio" }, + "video": { "type": "video_url", "label": "Reference video" }, "source": { "type": "metadata" }, "group": { "type": "task_group_id" } } diff --git a/scripts/manual-tests/test_audio_batch_preview.go b/scripts/manual-tests/audio_batch_preview/main.go similarity index 99% rename from scripts/manual-tests/test_audio_batch_preview.go rename to scripts/manual-tests/audio_batch_preview/main.go index 5d2629c..5cfbf0c 100644 --- a/scripts/manual-tests/test_audio_batch_preview.go +++ b/scripts/manual-tests/audio_batch_preview/main.go @@ -136,7 +136,7 @@ func repoRoot() (string, error) { return "", fmt.Errorf("failed to resolve script path") } - return filepath.Abs(filepath.Join(filepath.Dir(filePath), "..", "..")) + return filepath.Abs(filepath.Join(filepath.Dir(filePath), "..", "..", "..")) } func buildCLI(repoRoot, cliBinary string) error { diff --git a/scripts/manual-tests/video_batch_preview/main.go b/scripts/manual-tests/video_batch_preview/main.go new file mode 100644 index 0000000..dea34c3 --- /dev/null +++ b/scripts/manual-tests/video_batch_preview/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +const defaultWorkspaceID = "679271425fe00981084a5f58" + +var fieldPattern = regexp.MustCompile(`(?m)^([A-Za-z ]+):\s*(\S+)$`) + +func main() { + repoRoot, err := repoRoot() + if err != nil { + fatal(err) + } + + workspaceID := defaultWorkspaceID + if len(os.Args) > 1 { + workspaceID = os.Args[1] + } + + cliBinary := filepath.Join(os.TempDir(), "prolific-cli") + if err := buildCLI(repoRoot, cliBinary); err != nil { + fatal(err) + } + + schema := `{"fields":{"question":{"type":"text","label":"Question"},"clip":{"type":"video_url","label":"Video clip"}}}` + batchItems := `[{"rows":[{"columns":[{"items":[{"type":"dataset_field","field":"question"},{"type":"dataset_field","field":"clip"},{"type":"free_text","description":"Please describe what you saw."}]}]}]}]` + + // Create a V4 dataset whose schema includes a video_url field. + output, err := run(repoRoot, cliBinary, + "aitaskbuilder", "dataset", "create", + "-n", "Video URL Test Dataset", + "-w", workspaceID, + "--strict", + "--schema", schema, + ) + if err != nil { + fatal(err) + } + + datasetID, err := extractField(output, "ID") + if err != nil { + fatal(err) + } + fmt.Printf("Created dataset: %s\n", datasetID) + + csvPath := filepath.Join(os.TempDir(), "video-dataset.csv") + csvContents := strings.Join([]string{ + "question,clip", + `"What is happening in this clip?","https://www.w3schools.com/html/mov_bbb.mp4"`, + `"Describe the scene.","https://www.w3schools.com/html/movie.mp4"`, + "", + }, "\n") + if err := os.WriteFile(csvPath, []byte(csvContents), 0o600); err != nil { + fatal(fmt.Errorf("failed to write CSV fixture: %w", err)) + } + + invalidCSVPath := filepath.Join(os.TempDir(), "video-dataset-invalid.csv") + invalidCSVContents := strings.Join([]string{ + "question,clip", + `"This row should fail validation.","https://example.com/not-video.txt"`, + "", + }, "\n") + if err := os.WriteFile(invalidCSVPath, []byte(invalidCSVContents), 0o600); err != nil { + fatal(fmt.Errorf("failed to write invalid CSV fixture: %w", err)) + } + + // Attempt an invalid upload first to confirm video_url extension validation rejects non-video URLs. + if _, err := runAllowFailure(repoRoot, cliBinary, "aitaskbuilder", "dataset", "upload", "-d", datasetID, "-f", invalidCSVPath); err == nil { + fatal(fmt.Errorf("expected invalid video URL upload to fail")) + } else { + fmt.Println("Confirmed invalid video URL upload was rejected.") + } + + // Upload valid sample data containing supported video URL extensions. + if _, err := run(repoRoot, cliBinary, "aitaskbuilder", "dataset", "upload", "-d", datasetID, "-f", csvPath); err != nil { + fatal(err) + } + + // Check dataset status for extra visibility while running the manual flow. + _, _ = runAllowFailure(repoRoot, cliBinary, "aitaskbuilder", "dataset", "check", "-d", datasetID) + + // Create a batch linked to the dataset and define the participant layout via batch_items. + // batch_items replaces the deprecated standalone instructions endpoint, and this + // manual flow intentionally references the video_url dataset field even though + // the checked-in OpenAPI file has not caught up with the backend yet. + output, err = run(repoRoot, cliBinary, + "aitaskbuilder", "batch", "create", + "-n", "Video URL Test Batch", + "-w", workspaceID, + "-d", datasetID, + "--task-name", "Video Review Task", + "--task-introduction", "Watch the video clip and answer the question.", + "--task-steps", "1. Watch the clip\\n2. Answer the question", + "--batch-items-json", batchItems, + ) + if err != nil { + fatal(err) + } + + batchID, err := extractField(output, "ID") + if err != nil { + fatal(err) + } + fmt.Printf("Created batch: %s\n", batchID) + + // Set up the batch so the persisted preview route has a task group to open. + if _, err := run(repoRoot, cliBinary, + "aitaskbuilder", "batch", "setup", + "-b", batchID, + "-d", datasetID, + "--tasks-per-group", "1", + ); err != nil { + fatal(err) + } + + // Preview the batch through the researcher preview URL flow. + if _, err := run(repoRoot, cliBinary, "aitaskbuilder", "batch", "preview", batchID); err != nil { + fatal(err) + } + + fmt.Println("\nDone. Review output above for correctness.") +} + +func repoRoot() (string, error) { + _, filePath, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("failed to resolve script path") + } + + return filepath.Abs(filepath.Join(filepath.Dir(filePath), "..", "..", "..")) +} + +func buildCLI(repoRoot, cliBinary string) error { + fmt.Println("Building CLI...") + cmd := exec.CommandContext(context.Background(), "go", "build", "-o", cliBinary, ".") + _, err := runWithCheck(true, repoRoot, "go", cmd) + return err +} + +func run(repoRoot, cliBinary string, args ...string) (string, error) { + // #nosec G702 -- cliBinary is the local binary built by this script, not shell-expanded user input. + cmd := exec.CommandContext(context.Background(), cliBinary, args...) + return runWithCheck(true, repoRoot, cliBinary, cmd) +} + +func runAllowFailure(repoRoot, cliBinary string, args ...string) (string, error) { + // #nosec G702 -- cliBinary is the local binary built by this script, not shell-expanded user input. + cmd := exec.CommandContext(context.Background(), cliBinary, args...) + return runWithCheck(false, repoRoot, cliBinary, cmd) +} + +func runWithCheck(check bool, repoRoot, command string, cmd *exec.Cmd) (string, error) { + fmt.Printf("\n$ %s %s\n", command, strings.Join(cmd.Args[1:], " ")) + + cmd.Dir = repoRoot + cmd.Env = os.Environ() + + output, err := cmd.CombinedOutput() + text := string(output) + fmt.Print(text) + + if err != nil && check { + return text, fmt.Errorf("command failed: %w", err) + } + + return text, err +} + +func extractField(output, label string) (string, error) { + matches := fieldPattern.FindAllStringSubmatch(output, -1) + for _, match := range matches { + if len(match) >= 3 && match[1] == label { + return match[2], nil + } + } + + return "", fmt.Errorf("could not find %q in output", label) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} From 5054291837d154ee733a6946dff06c8bb158941d Mon Sep 17 00:00:00 2001 From: Desy Kristianti Date: Wed, 22 Jul 2026 10:49:11 +0100 Subject: [PATCH 2/3] perf(DCT-91): validate audio and video URL fields in a single file pass Audio and video URL validation each scanned the upload file fully, doubling read time for datasets with both field types. --- cmd/aitaskbuilder/upload_dataset.go | 90 +++++++++++++---------------- 1 file changed, 41 insertions(+), 49 deletions(-) diff --git a/cmd/aitaskbuilder/upload_dataset.go b/cmd/aitaskbuilder/upload_dataset.go index 00cc407..7936078 100644 --- a/cmd/aitaskbuilder/upload_dataset.go +++ b/cmd/aitaskbuilder/upload_dataset.go @@ -137,11 +137,7 @@ func uploadDatasetFile(client client.API, opts DatasetUploadOptions, w io.Writer return fmt.Errorf("failed to get dataset: %w", err) } - if err := validateAudioURLFields(opts.FilePath, uploadRequest.Format, dataset.Schema); err != nil { - return err - } - - if err := validateVideoURLFields(opts.FilePath, uploadRequest.Format, dataset.Schema); err != nil { + if err := validateMediaURLFields(opts.FilePath, uploadRequest.Format, dataset.Schema); err != nil { return err } @@ -292,54 +288,47 @@ func uploadFileToPresignedURL(filePath, uploadURL, method, contentType string) e return nil } -func validateAudioURLFields(filePath string, format model.DatasetImportFormat, schema *client.DatasetSchema) error { - return validateMediaURLFields(filePath, format, schema, "audio_url", "audio", validAudioURLFileExtensions, supportedAudioURLFileExtensions) +// mediaURLFieldConfig describes how to validate the values of a single media URL field. +type mediaURLFieldConfig struct { + mediaLabel string + extensions map[string]bool + supportedExtensions string } -func validateVideoURLFields(filePath string, format model.DatasetImportFormat, schema *client.DatasetSchema) error { - return validateMediaURLFields(filePath, format, schema, "video_url", "video", validVideoURLFileExtensions, supportedVideoURLFileExtensions) +// mediaURLFieldConfigsByType maps a dataset schema field type to its media URL validation config. +var mediaURLFieldConfigsByType = map[string]mediaURLFieldConfig{ + "audio_url": {mediaLabel: "audio", extensions: validAudioURLFileExtensions, supportedExtensions: supportedAudioURLFileExtensions}, + "video_url": {mediaLabel: "video", extensions: validVideoURLFileExtensions, supportedExtensions: supportedVideoURLFileExtensions}, } -func validateMediaURLFields( - filePath string, - format model.DatasetImportFormat, - schema *client.DatasetSchema, - fieldType, mediaLabel string, - extensions map[string]bool, - supportedExtensions string, -) error { +// validateMediaURLFields validates all media URL fields (audio and video) in a single pass over the upload file. +func validateMediaURLFields(filePath string, format model.DatasetImportFormat, schema *client.DatasetSchema) error { if schema == nil { return nil } - mediaFields := make(map[string]struct{}) + mediaFieldConfigs := make(map[string]mediaURLFieldConfig) for fieldName, field := range schema.Fields { - if field.Type == fieldType { - mediaFields[fieldName] = struct{}{} + if config, ok := mediaURLFieldConfigsByType[field.Type]; ok { + mediaFieldConfigs[fieldName] = config } } - if len(mediaFields) == 0 { + if len(mediaFieldConfigs) == 0 { return nil } switch format { case model.DatasetImportFormatCSV: - return validateMediaURLFieldsInCSV(filePath, mediaFields, mediaLabel, extensions, supportedExtensions) + return validateMediaURLFieldsInCSV(filePath, mediaFieldConfigs) case model.DatasetImportFormatJSONL: - return validateMediaURLFieldsInJSONL(filePath, mediaFields, mediaLabel, extensions, supportedExtensions) + return validateMediaURLFieldsInJSONL(filePath, mediaFieldConfigs) default: return nil } } -func validateMediaURLFieldsInCSV( - filePath string, - mediaFields map[string]struct{}, - mediaLabel string, - extensions map[string]bool, - supportedExtensions string, -) error { +func validateMediaURLFieldsInCSV(filePath string, mediaFieldConfigs map[string]mediaURLFieldConfig) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file %s: %w", filePath, err) @@ -355,7 +344,7 @@ func validateMediaURLFieldsInCSV( mediaColumnIndexes := make(map[int]string) for idx, header := range headers { fieldName := strings.TrimSpace(header) - if _, ok := mediaFields[fieldName]; ok { + if _, ok := mediaFieldConfigs[fieldName]; ok { mediaColumnIndexes[idx] = fieldName } } @@ -379,7 +368,7 @@ func validateMediaURLFieldsInCSV( continue } - if err := validateMediaURLValue(recordIndex, fieldName, record[idx], mediaLabel, extensions, supportedExtensions); err != nil { + if err := validateMediaURLValue(recordIndex, fieldName, record[idx], mediaFieldConfigs[fieldName]); err != nil { return err } } @@ -389,20 +378,23 @@ func validateMediaURLFieldsInCSV( } func ValidateAudioURLFieldsInJSONL(filePath string, audioFields map[string]struct{}) error { - return validateMediaURLFieldsInJSONL(filePath, audioFields, "audio", validAudioURLFileExtensions, supportedAudioURLFileExtensions) + return validateMediaURLFieldsInJSONL(filePath, mediaFieldConfigMap(audioFields, mediaURLFieldConfigsByType["audio_url"])) } func ValidateVideoURLFieldsInJSONL(filePath string, videoFields map[string]struct{}) error { - return validateMediaURLFieldsInJSONL(filePath, videoFields, "video", validVideoURLFileExtensions, supportedVideoURLFileExtensions) + return validateMediaURLFieldsInJSONL(filePath, mediaFieldConfigMap(videoFields, mediaURLFieldConfigsByType["video_url"])) } -func validateMediaURLFieldsInJSONL( - filePath string, - mediaFields map[string]struct{}, - mediaLabel string, - extensions map[string]bool, - supportedExtensions string, -) error { +func mediaFieldConfigMap(fields map[string]struct{}, config mediaURLFieldConfig) map[string]mediaURLFieldConfig { + mediaFieldConfigs := make(map[string]mediaURLFieldConfig, len(fields)) + for fieldName := range fields { + mediaFieldConfigs[fieldName] = config + } + + return mediaFieldConfigs +} + +func validateMediaURLFieldsInJSONL(filePath string, mediaFieldConfigs map[string]mediaURLFieldConfig) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open file %s: %w", filePath, err) @@ -425,7 +417,7 @@ func validateMediaURLFieldsInJSONL( return fmt.Errorf("failed to parse JSONL record %d from %s: %w", recordIndex, filePath, err) } - for fieldName := range mediaFields { + for fieldName, config := range mediaFieldConfigs { value, ok := record[fieldName] if !ok || value == nil { continue @@ -437,12 +429,12 @@ func validateMediaURLFieldsInJSONL( "record %d field %s: %s URL must be a string ending with one of %s", recordIndex, fieldName, - mediaLabel, - supportedExtensions, + config.mediaLabel, + config.supportedExtensions, ) } - if err := validateMediaURLValue(recordIndex, fieldName, valueString, mediaLabel, extensions, supportedExtensions); err != nil { + if err := validateMediaURLValue(recordIndex, fieldName, valueString, config); err != nil { return err } } @@ -457,20 +449,20 @@ func validateMediaURLFieldsInJSONL( return nil } -func validateMediaURLValue(recordIndex int, fieldName, value, mediaLabel string, extensions map[string]bool, supportedExtensions string) error { +func validateMediaURLValue(recordIndex int, fieldName, value string, config mediaURLFieldConfig) error { trimmedValue := strings.TrimSpace(value) if trimmedValue == "" { return nil } - if !hasSupportedURLExtension(trimmedValue, extensions) { + if !hasSupportedURLExtension(trimmedValue, config.extensions) { return fmt.Errorf( "record %d field %s: %s URL %q must end with one of %s", recordIndex, fieldName, - mediaLabel, + config.mediaLabel, trimmedValue, - supportedExtensions, + config.supportedExtensions, ) } From eff593ebc87570af3216d677db0dc88cce0c6491 Mon Sep 17 00:00:00 2001 From: Desy Kristianti Date: Wed, 22 Jul 2026 13:24:03 +0100 Subject: [PATCH 3/3] fix(DCT-91): update supported extensions --- cmd/aitaskbuilder/upload_dataset.go | 5 ++--- cmd/aitaskbuilder/upload_dataset_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/aitaskbuilder/upload_dataset.go b/cmd/aitaskbuilder/upload_dataset.go index 7936078..bda5742 100644 --- a/cmd/aitaskbuilder/upload_dataset.go +++ b/cmd/aitaskbuilder/upload_dataset.go @@ -42,12 +42,11 @@ const supportedAudioURLFileExtensions = ".aac, .m4a, .mp3, .wav" var validVideoURLFileExtensions = map[string]bool{ ".mp4": true, - ".mov": true, ".webm": true, - ".avi": true, + ".mov": true, } -const supportedVideoURLFileExtensions = ".mp4, .mov, .webm, .avi" +const supportedVideoURLFileExtensions = ".mp4, .webm, .mov" // DatasetUploadOptions are the options for uploading to an AI Task Builder dataset. type DatasetUploadOptions struct { diff --git a/cmd/aitaskbuilder/upload_dataset_test.go b/cmd/aitaskbuilder/upload_dataset_test.go index 422d87a..fd60d78 100644 --- a/cmd/aitaskbuilder/upload_dataset_test.go +++ b/cmd/aitaskbuilder/upload_dataset_test.go @@ -270,7 +270,7 @@ func TestDatasetUploadCommandRejectsUnsupportedCSVMediaExtension(t *testing.T) { fieldType: "video_url", datasetID: "dataset-video", unsupportedURL: "https://example.com/video.txt", - supportedExtensions: ".mp4, .mov, .webm, .avi", + supportedExtensions: ".mp4, .webm, .mov", }, }