diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index f2b3a999..0b767d6f 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1147,6 +1147,15 @@ components: $ref: '#/components/schemas/ErrorResponse' description: Precondition Failed schemas: + AIModel: + properties: + description: + type: string + id: + type: string + name: + type: string + type: object AIModelItem: properties: brick_ids: @@ -1314,6 +1323,11 @@ components: type: string id: type: string + models: + items: + $ref: '#/components/schemas/AIModel' + nullable: true + type: array name: type: string readme: @@ -1365,11 +1379,6 @@ components: type: string id: type: string - models: - items: - type: string - nullable: true - type: array name: type: string status: diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index f6094430..2325f388 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -1,6 +1,6 @@ // Package client provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. package client import ( @@ -41,6 +41,13 @@ const ( StarsDesc ListLibrariesParamsSort = "stars_desc" ) +// AIModel defines model for AIModel. +type AIModel struct { + Description *string `json:"description,omitempty"` + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + // AIModelItem defines model for AIModelItem. type AIModelItem struct { BrickIds *[]string `json:"brick_ids"` @@ -141,6 +148,7 @@ type BrickDetailsResult struct { CodeExamples *[]CodeExample `json:"code_examples"` Description *string `json:"description,omitempty"` Id *string `json:"id,omitempty"` + Models *[]AIModel `json:"models"` Name *string `json:"name,omitempty"` Readme *string `json:"readme,omitempty"` Status *string `json:"status,omitempty"` @@ -164,13 +172,12 @@ type BrickInstance struct { // BrickListItem defines model for BrickListItem. type BrickListItem struct { - Author *string `json:"author,omitempty"` - Category *string `json:"category,omitempty"` - Description *string `json:"description,omitempty"` - Id *string `json:"id,omitempty"` - Models *[]string `json:"models"` - Name *string `json:"name,omitempty"` - Status *string `json:"status,omitempty"` + Author *string `json:"author,omitempty"` + Category *string `json:"category,omitempty"` + Description *string `json:"description,omitempty"` + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Status *string `json:"status,omitempty"` } // BrickListResult defines model for BrickListResult. diff --git a/internal/e2e/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index fa1cab40..488a670a 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -115,6 +115,17 @@ func TestBricksDetails(t *testing.T) { }, } + expectedModelLiteInfo := []client.AIModel{ + { + Id: f.Ptr("mobilenet-image-classification"), + Name: f.Ptr("General purpose image classification"), + Description: f.Ptr("General purpose image classification model based on MobileNetV2. This model is trained on the ImageNet dataset and can classify images into 1000 categories."), + }, + { + Id: f.Ptr("person-classification"), + Name: f.Ptr("Person classification"), + Description: f.Ptr("Person classification model based on WakeVision dataset. This model is trained to classify images into two categories: person and not-person."), + }} response, err := httpClient.GetBrickDetailsWithResponse(t.Context(), validBrickID, func(ctx context.Context, req *http.Request) error { return nil }) require.NoError(t, err) require.Equal(t, http.StatusOK, response.StatusCode(), "status code should be 200 ok") @@ -133,5 +144,7 @@ func TestBricksDetails(t *testing.T) { require.NotEmpty(t, *response.JSON200.Readme) require.NotNil(t, response.JSON200.UsedByApps, "UsedByApps should not be nil") require.Equal(t, expectedUsedByApps, *(response.JSON200.UsedByApps)) + require.NotNil(t, response.JSON200.Models, "Models should not be nil") + require.Equal(t, expectedModelLiteInfo, *(response.JSON200.Models)) }) } diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index e8dea9da..ae3cd3aa 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -64,9 +64,6 @@ func (s *Service) List() (BrickListResult, error) { Description: brick.Description, Category: brick.Category, Status: "installed", - Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) string { - return m.ID - }), } } return res, nil @@ -193,7 +190,6 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, if err != nil { return BrickDetailsResult{}, fmt.Errorf("unable to get used by apps: %w", err) } - return BrickDetailsResult{ ID: id, Name: brick.Name, @@ -206,6 +202,13 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, ApiDocsPath: apiDocsPath, CodeExamples: codeExamples, UsedByApps: usedByApps, + Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel { + return AIModel{ + ID: m.ID, + Name: m.Name, + Description: m.ModuleDescription, + } + }), }, nil } diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index 2f03d96b..19e21f0e 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -16,6 +16,8 @@ package bricks import ( + "os" + "path/filepath" "testing" "github.com/arduino/go-paths-helper" @@ -24,6 +26,9 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" + "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" + "github.com/arduino/arduino-app-cli/internal/store" ) func TestBrickCreate(t *testing.T) { @@ -190,3 +195,169 @@ func TestGetBrickInstanceVariableDetails(t *testing.T) { }) } } + +func TestBricksDetails(t *testing.T) { + tmpDir := t.TempDir() + appsDir := filepath.Join(tmpDir, "ArduinoApps") + dataDir := filepath.Join(tmpDir, "Data") + assetsDir := filepath.Join(dataDir, "assets") + + require.NoError(t, os.MkdirAll(appsDir, 0755)) + require.NoError(t, os.MkdirAll(assetsDir, 0755)) + + t.Setenv("ARDUINO_APP_CLI__APPS_DIR", appsDir) + t.Setenv("ARDUINO_APP_CLI__DATA_DIR", dataDir) + + cfg, err := config.NewFromEnv() + require.NoError(t, err) + + for _, brick := range []string{"object_detection", "weather_forecast", "one_model_brick"} { + createFakeBrickAssets(t, assetsDir, brick) + } + createFakeApp(t, appsDir) + + bIndex := &bricksindex.BricksIndex{ + Bricks: []bricksindex.Brick{ + { + ID: "arduino:object_detection", + Name: "Object Detection", + Category: "video", + ModelName: "yolox-object-detection", // Default model + Variables: []bricksindex.BrickVariable{ + {Name: "EI_OBJ_DETECTION_MODEL", DefaultValue: "default_path", Description: "path to the model file"}, + {Name: "CUSTOM_MODEL_PATH", DefaultValue: "/home/arduino/.arduino-bricks/ei-models", Description: "path to the custom model directory"}, + }, + }, + { + ID: "arduino:weather_forecast", + Name: "Weather Forecast", + Category: "miscellaneous", + ModelName: "", + }, + { + ID: "arduino:one_model_brick", + Name: "one model brick", + Category: "video", + ModelName: "face-detection", // Default model + Variables: []bricksindex.BrickVariable{}, + }, + }, + } + mIndex := &modelsindex.ModelsIndex{ + Models: []modelsindex.AIModel{ + + { + ID: "yolox-object-detection", + Name: "General purpose object detection - YoloX", + ModuleDescription: "General purpose object detection...", + Bricks: []string{"arduino:object_detection", "arduino:video_object_detection"}, + }, + { + ID: "face-detection", + Name: "Lightweight-Face-Detection", + Bricks: []string{"arduino:object_detection", "arduino:video_object_detection", "arduino:one_model_brick"}, + }, + }} + + svc := &Service{ + bricksIndex: bIndex, + modelsIndex: mIndex, + staticStore: store.NewStaticStore(assetsDir), + } + idProvider := app.NewAppIDProvider(cfg) + + t.Run("Brick Not Found", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:non_existing", idProvider, cfg) + require.Error(t, err) + require.Equal(t, ErrBrickNotFound, err) + require.Empty(t, res.ID) + }) + + t.Run("Success - Full Details - multiple models", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:object_detection", idProvider, cfg) + require.NoError(t, err) + + require.Equal(t, "arduino:object_detection", res.ID) + require.Equal(t, "Object Detection", res.Name) + require.Equal(t, "Arduino", res.Author) + require.Equal(t, "installed", res.Status) + require.Contains(t, res.Variables, "EI_OBJ_DETECTION_MODEL") + require.Equal(t, "default_path", res.Variables["EI_OBJ_DETECTION_MODEL"].DefaultValue) + require.Equal(t, "# Documentation", res.Readme) + require.Contains(t, res.ApiDocsPath, filepath.Join("arduino", "app_bricks", "object_detection", "API.md")) + require.Len(t, res.CodeExamples, 1) + require.Contains(t, res.CodeExamples[0].Path, "blink.ino") + require.Len(t, res.UsedByApps, 1) + require.Equal(t, "My App", res.UsedByApps[0].Name) + require.NotEmpty(t, res.UsedByApps[0].ID) + require.Len(t, res.Models, 2) + require.Equal(t, "yolox-object-detection", res.Models[0].ID) + require.Equal(t, "General purpose object detection - YoloX", res.Models[0].Name) + require.Equal(t, "General purpose object detection...", res.Models[0].Description) + require.Equal(t, "face-detection", res.Models[1].ID) + require.Equal(t, "Lightweight-Face-Detection", res.Models[1].Name) + require.Equal(t, "", res.Models[1].Description) + }) + + t.Run("Success - Full Details - no models", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:weather_forecast", idProvider, cfg) + require.NoError(t, err) + + require.Equal(t, "arduino:weather_forecast", res.ID) + require.Equal(t, "Weather Forecast", res.Name) + require.Equal(t, "Arduino", res.Author) + require.Equal(t, "installed", res.Status) + require.Empty(t, res.Variables) + require.Equal(t, "# Documentation", res.Readme) + require.Contains(t, res.ApiDocsPath, filepath.Join("arduino", "app_bricks", "weather_forecast", "API.md")) + require.Len(t, res.CodeExamples, 1) + require.Contains(t, res.CodeExamples[0].Path, "blink.ino") + require.Len(t, res.UsedByApps, 1) + require.Equal(t, "My App", res.UsedByApps[0].Name) + require.NotEmpty(t, res.UsedByApps[0].ID) + require.Len(t, res.Models, 0) + }) + + t.Run("Success - Full Details - one model", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:one_model_brick", idProvider, cfg) + require.NoError(t, err) + + require.Equal(t, "arduino:one_model_brick", res.ID) + require.Equal(t, "one model brick", res.Name) + require.Len(t, res.Models, 1) + require.Equal(t, "face-detection", res.Models[0].ID) + require.Equal(t, "Lightweight-Face-Detection", res.Models[0].Name) + require.Equal(t, "", res.Models[0].Description) + }) +} + +func createFakeBrickAssets(t *testing.T, assetsDir, brick string) { + t.Helper() + + brickDocDir := filepath.Join(assetsDir, "docs", "arduino", brick) + require.NoError(t, os.MkdirAll(brickDocDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(brickDocDir, "README.md"), + []byte("# Documentation"), 0600)) + + brickExDir := filepath.Join(assetsDir, "examples", "arduino", brick) + require.NoError(t, os.MkdirAll(brickExDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(brickExDir, "blink.ino"), + []byte("void setup() {}"), 0600)) +} + +func createFakeApp(t *testing.T, appsDir string) { + t.Helper() + myAppDir := filepath.Join(appsDir, "MyApp") + require.NoError(t, os.MkdirAll(myAppDir, 0755)) + + appYamlContent := ` +name: My App +bricks: + - arduino:object_detection: + - arduino:weather_forecast: +` + require.NoError(t, os.WriteFile(filepath.Join(myAppDir, "app.yaml"), []byte(appYamlContent), 0600)) + pythonDir := filepath.Join(myAppDir, "python") + require.NoError(t, os.MkdirAll(pythonDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pythonDir, "main.py"), []byte("print('hello')"), 0600)) +} diff --git a/internal/orchestrator/bricks/types.go b/internal/orchestrator/bricks/types.go index 868c563a..f27b0652 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -20,13 +20,12 @@ type BrickListResult struct { } type BrickListItem struct { - ID string `json:"id"` - Name string `json:"name"` - Author string `json:"author"` - Description string `json:"description"` - Category string `json:"category"` - Status string `json:"status"` - Models []string `json:"models"` + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + Description string `json:"description"` + Category string `json:"category"` + Status string `json:"status"` } type AppBrickInstancesResult struct { @@ -78,4 +77,11 @@ type BrickDetailsResult struct { ApiDocsPath string `json:"api_docs_path"` CodeExamples []CodeExample `json:"code_examples"` UsedByApps []AppReference `json:"used_by_apps"` + Models []AIModel `json:"models"` +} + +type AIModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` } diff --git a/internal/orchestrator/modelsindex/models_index.go b/internal/orchestrator/modelsindex/models_index.go index e18797f1..c9a47347 100644 --- a/internal/orchestrator/modelsindex/models_index.go +++ b/internal/orchestrator/modelsindex/models_index.go @@ -54,26 +54,26 @@ type AIModel struct { } type ModelsIndex struct { - models []AIModel + Models []AIModel } func (m *ModelsIndex) GetModels() []AIModel { - return m.models + return m.Models } func (m *ModelsIndex) GetModelByID(id string) (*AIModel, bool) { - idx := slices.IndexFunc(m.models, func(v AIModel) bool { return v.ID == id }) + idx := slices.IndexFunc(m.Models, func(v AIModel) bool { return v.ID == id }) if idx == -1 { return nil, false } - return &m.models[idx], true + return &m.Models[idx], true } func (m *ModelsIndex) GetModelsByBrick(brick string) []AIModel { var matches []AIModel - for i := range m.models { - if len(m.models[i].Bricks) > 0 && slices.Contains(m.models[i].Bricks, brick) { - matches = append(matches, m.models[i]) + for i := range m.Models { + if len(m.Models[i].Bricks) > 0 && slices.Contains(m.Models[i].Bricks, brick) { + matches = append(matches, m.Models[i]) } } if len(matches) == 0 { @@ -84,7 +84,7 @@ func (m *ModelsIndex) GetModelsByBrick(brick string) []AIModel { func (m *ModelsIndex) GetModelsByBricks(bricks []string) []AIModel { var matchingModels []AIModel - for _, model := range m.models { + for _, model := range m.Models { for _, modelBrick := range model.Bricks { if slices.Contains(bricks, modelBrick) { matchingModels = append(matchingModels, model) @@ -113,5 +113,5 @@ func GenerateModelsIndexFromFile(dir *paths.Path) (*ModelsIndex, error) { models[i] = model } } - return &ModelsIndex{models: models}, nil + return &ModelsIndex{Models: models}, nil }