From 623eea51d77c4302f914b100e138360fc4e3bdcb Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Mon, 10 Mar 2025 22:15:01 +0100 Subject: [PATCH 1/2] feat: genai toolkit --- api/genai/client.go | 225 ++++++++++++++++ api/genai/types.go | 201 +++++++++++++++ pkg/cmd/factory/default.go | 17 ++ pkg/cmd/genai/datasource/create/create.go | 111 ++++++++ .../genai/datasource/create/create_test.go | 182 +++++++++++++ pkg/cmd/genai/datasource/datasource.go | 35 +++ pkg/cmd/genai/datasource/delete/delete.go | 96 +++++++ pkg/cmd/genai/datasource/get/get.go | 96 +++++++ pkg/cmd/genai/datasource/list/list.go | 121 +++++++++ pkg/cmd/genai/datasource/update/update.go | 111 ++++++++ pkg/cmd/genai/genai.go | 30 +++ pkg/cmd/genai/prompt/create/create.go | 111 ++++++++ pkg/cmd/genai/prompt/delete/delete.go | 96 +++++++ pkg/cmd/genai/prompt/get/get.go | 96 +++++++ pkg/cmd/genai/prompt/list/list.go | 119 +++++++++ pkg/cmd/genai/prompt/prompt.go | 35 +++ pkg/cmd/genai/prompt/update/update.go | 111 ++++++++ pkg/cmd/genai/response/delete/delete.go | 89 +++++++ pkg/cmd/genai/response/generate/generate.go | 169 ++++++++++++ .../genai/response/generate/generate_test.go | 241 ++++++++++++++++++ pkg/cmd/genai/response/get/get.go | 105 ++++++++ pkg/cmd/genai/response/list/list.go | 128 ++++++++++ pkg/cmd/genai/response/response.go | 33 +++ pkg/cmd/root/root.go | 2 + pkg/cmdutil/factory.go | 2 + test/helpers.go | 6 + 26 files changed, 2568 insertions(+) create mode 100644 api/genai/client.go create mode 100644 api/genai/types.go create mode 100644 pkg/cmd/genai/datasource/create/create.go create mode 100644 pkg/cmd/genai/datasource/create/create_test.go create mode 100644 pkg/cmd/genai/datasource/datasource.go create mode 100644 pkg/cmd/genai/datasource/delete/delete.go create mode 100644 pkg/cmd/genai/datasource/get/get.go create mode 100644 pkg/cmd/genai/datasource/list/list.go create mode 100644 pkg/cmd/genai/datasource/update/update.go create mode 100644 pkg/cmd/genai/genai.go create mode 100644 pkg/cmd/genai/prompt/create/create.go create mode 100644 pkg/cmd/genai/prompt/delete/delete.go create mode 100644 pkg/cmd/genai/prompt/get/get.go create mode 100644 pkg/cmd/genai/prompt/list/list.go create mode 100644 pkg/cmd/genai/prompt/prompt.go create mode 100644 pkg/cmd/genai/prompt/update/update.go create mode 100644 pkg/cmd/genai/response/delete/delete.go create mode 100644 pkg/cmd/genai/response/generate/generate.go create mode 100644 pkg/cmd/genai/response/generate/generate_test.go create mode 100644 pkg/cmd/genai/response/get/get.go create mode 100644 pkg/cmd/genai/response/list/list.go create mode 100644 pkg/cmd/genai/response/response.go diff --git a/api/genai/client.go b/api/genai/client.go new file mode 100644 index 00000000..567511af --- /dev/null +++ b/api/genai/client.go @@ -0,0 +1,225 @@ +package genai + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +const ( + // DefaultBaseURL is the default base URL for the Algolia GenAI API + DefaultBaseURL = "https://generative-ai.algolia.com" +) + +// Client provides methods to interact with the Algolia GenAI API +type Client struct { + AppID string + APIKey string + client *http.Client +} + +// NewClient returns a new GenAI API client +func NewClient(appID, apiKey string) *Client { + return &Client{ + AppID: appID, + APIKey: apiKey, + client: http.DefaultClient, + } +} + +// NewClientWithHTTPClient returns a new GenAI API client with a custom HTTP client +func NewClientWithHTTPClient(appID, apiKey string, client *http.Client) *Client { + return &Client{ + AppID: appID, + APIKey: apiKey, + client: client, + } +} + +func (c *Client) request(res interface{}, method string, path string, body interface{}, urlParams map[string]string) error { + r, err := c.buildRequest(method, path, body, urlParams) + if err != nil { + return err + } + + resp, err := c.client.Do(r) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + var errResp ErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&errResp); err == nil { + return &APIError{ + StatusCode: resp.StatusCode, + Method: method, + Path: path, + Response: errResp, + } + } + + // Fallback if error response couldn't be parsed + return &APIError{ + StatusCode: resp.StatusCode, + Method: method, + Path: path, + Response: ErrorResponse{ + Message: "Error accessing API", + }, + } + } + + if res != nil { + if err := unmarshalTo(resp, res); err != nil { + return err + } + } + + return nil +} + +// buildRequest builds an HTTP request +func (c *Client) buildRequest(method, path string, body interface{}, urlParams map[string]string) (*http.Request, error) { + path = strings.TrimSuffix(path, "/") + url := DefaultBaseURL + path + + var req *http.Request + var err error + + if body == nil { + req, err = http.NewRequest(method, url, nil) + } else { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err = http.NewRequest(method, url, bytes.NewReader(b)) + if err != nil { + return nil, err + } + } + + if err != nil { + return nil, err + } + + req.Header.Set("X-Algolia-Application-Id", c.AppID) + req.Header.Set("X-Algolia-API-Key", c.APIKey) + req.Header.Set("Content-Type", "application/json") + + // Add URL params + values := req.URL.Query() + for k, v := range urlParams { + values.Set(k, v) + } + req.URL.RawQuery = values.Encode() + + return req, nil +} + +// unmarshalTo unmarshals an HTTP response body to a given interface +func unmarshalTo(r *http.Response, v interface{}) error { + // Don't close the body here as it's now closed in the request method + return json.NewDecoder(r.Body).Decode(v) +} + +// CreateDataSource creates a new data source +func (c *Client) CreateDataSource(input CreateDataSourceInput) (*DataSourceResponse, error) { + var res DataSourceResponse + err := c.request(&res, http.MethodPost, "/create/data_source", input, nil) + return &res, err +} + +// GetDataSource gets a data source by ID +func (c *Client) GetDataSource(objectID string) (*DataSourceDetails, error) { + var res DataSourceDetails + err := c.request(&res, http.MethodGet, fmt.Sprintf("/get/data_source/%s", objectID), nil, nil) + return &res, err +} + +// ListDataSources lists all data sources +// Note: Not supported by the API yet +func (c *Client) ListDataSources() (*ListDataSourcesResponse, error) { + // The API doesn't have a list endpoint (yet) + return nil, fmt.Errorf("listing data sources is not currently supported by the Algolia GenAI API") +} + +// UpdateDataSource updates an existing data source +func (c *Client) UpdateDataSource(input UpdateDataSourceInput) (*DataSourceResponse, error) { + var res DataSourceResponse + err := c.request(&res, http.MethodPost, "/update/data_source", input, nil) + return &res, err +} + +// DeleteDataSources deletes one or more data sources +func (c *Client) DeleteDataSources(input DeleteDataSourcesInput) (*DeleteResponse, error) { + var res DeleteResponse + err := c.request(&res, http.MethodPost, "/delete/data_sources", input, nil) + return &res, err +} + +// CreatePrompt creates a new prompt +func (c *Client) CreatePrompt(input CreatePromptInput) (*PromptResponse, error) { + var res PromptResponse + err := c.request(&res, http.MethodPost, "/create/prompt", input, nil) + return &res, err +} + +// GetPrompt gets a prompt by ID +func (c *Client) GetPrompt(objectID string) (*PromptDetails, error) { + var res PromptDetails + err := c.request(&res, http.MethodGet, fmt.Sprintf("/get/prompt/%s", objectID), nil, nil) + return &res, err +} + +// ListPrompts lists all prompts +// Note: Not supported by the API yet +func (c *Client) ListPrompts() (*ListPromptsResponse, error) { + // The API doesn't seem to have a list endpoint + return nil, fmt.Errorf("listing prompts is not currently supported by the Algolia GenAI API") +} + +// UpdatePrompt updates an existing prompt +func (c *Client) UpdatePrompt(input UpdatePromptInput) (*PromptResponse, error) { + var res PromptResponse + err := c.request(&res, http.MethodPost, "/update/prompt", input, nil) + return &res, err +} + +// DeletePrompts deletes one or more prompts +func (c *Client) DeletePrompts(input DeletePromptsInput) (*DeleteResponse, error) { + var res DeleteResponse + err := c.request(&res, http.MethodPost, "/delete/prompts", input, nil) + return &res, err +} + +// GenerateResponse generates a response using a prompt +func (c *Client) GenerateResponse(input GenerateResponseInput) (*GenerateResponseOutput, error) { + var res GenerateResponseOutput + err := c.request(&res, http.MethodPost, "/generate/response", input, nil) + return &res, err +} + +// ListResponses lists all responses +// Note: Not supported by the API yet +func (c *Client) ListResponses() (*ListResponsesResponse, error) { + // The API doesn't seem to have a list endpoint + return nil, fmt.Errorf("listing responses is not currently supported by the Algolia GenAI API") +} + +// GetResponse retrieves a response by ID +func (c *Client) GetResponse(objectID string) (*ResponseDetails, error) { + var res ResponseDetails + err := c.request(&res, http.MethodGet, fmt.Sprintf("/get/response/%s", objectID), nil, nil) + return &res, err +} + +// DeleteResponses deletes one or more responses +func (c *Client) DeleteResponses(input DeleteResponsesInput) (*DeleteResponse, error) { + var res DeleteResponse + err := c.request(&res, http.MethodPost, "/delete/responses", input, nil) + return &res, err +} diff --git a/api/genai/types.go b/api/genai/types.go new file mode 100644 index 00000000..093bce44 --- /dev/null +++ b/api/genai/types.go @@ -0,0 +1,201 @@ +package genai + +import ( + "fmt" + "time" +) + +// ErrorResponse represents an error response from the API +type ErrorResponse struct { + Name string `json:"name"` + Message string `json:"message"` + Status int `json:"status,omitempty"` + Details string `json:"details,omitempty"` +} + +// APIError represents a more structured error with HTTP and API context +type APIError struct { + StatusCode int + Method string + Path string + Response ErrorResponse +} + +// Error implements the error interface for APIError +func (e *APIError) Error() string { + if e.Response.Message != "" { + return fmt.Sprintf("[%d] %s: %s %s", e.StatusCode, e.Response.Message, e.Method, e.Path) + } + return fmt.Sprintf("[%d] Error accessing: %s %s", e.StatusCode, e.Method, e.Path) +} + +// DeleteResponse represents a successful deletion response +type DeleteResponse struct { + Message string `json:"message"` +} + +// DataSourceResponse represents a response when creating/updating a data source +type DataSourceResponse struct { + ObjectID string `json:"objectID"` +} + +// DataSource represents a data source in the GenAI API +type DataSource struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Config struct { + IndexName string `json:"indexName"` + AppID string `json:"appId"` + } `json:"config"` +} + +// DataSourceDetails represents a detailed data source returned by the get endpoint +type DataSourceDetails struct { + Status int `json:"status"` + Name string `json:"name"` + Source string `json:"source"` + Filters string `json:"filters,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LinkedResponses int `json:"linkedResponses"` + ObjectID string `json:"objectID"` +} + +// CreateDataSourceInput represents the input for creating a data source +type CreateDataSourceInput struct { + Name string `json:"name"` + Source string `json:"source"` + Filters string `json:"filters,omitempty"` + ObjectID string `json:"objectID,omitempty"` +} + +// UpdateDataSourceInput represents the input for updating a data source +type UpdateDataSourceInput struct { + ObjectID string `json:"objectID"` + Name string `json:"name,omitempty"` + Source string `json:"source,omitempty"` + Filters string `json:"filters,omitempty"` +} + +// DeleteDataSourcesInput represents the input for deleting data sources +type DeleteDataSourcesInput struct { + ObjectIDs []string `json:"objectIDs"` + DeleteLinkedResponses bool `json:"deleteLinkedResponses,omitempty"` +} + +// ListDataSourcesResponse represents the response from listing data sources +type ListDataSourcesResponse struct { + DataSources []DataSource `json:"dataSources"` +} + +// PromptResponse represents a response when creating/updating a prompt +type PromptResponse struct { + ObjectID string `json:"objectID"` +} + +// Prompt represents a prompt in the GenAI API +type Prompt struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PromptDetails represents a detailed prompt returned by the get endpoint +type PromptDetails struct { + Status int `json:"status"` + Name string `json:"name"` + Instructions string `json:"instructions"` + Tone string `json:"tone,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LinkedResponses int `json:"linkedResponses"` + ObjectID string `json:"objectID"` +} + +// CreatePromptInput represents the input for creating a prompt +type CreatePromptInput struct { + Name string `json:"name"` + Instructions string `json:"instructions"` + Tone string `json:"tone,omitempty"` + ObjectID string `json:"objectID,omitempty"` +} + +// UpdatePromptInput represents the input for updating a prompt +type UpdatePromptInput struct { + ObjectID string `json:"objectID"` + Name string `json:"name,omitempty"` + Instructions string `json:"instructions,omitempty"` + Tone string `json:"tone,omitempty"` +} + +// DeletePromptsInput represents the input for deleting prompts +type DeletePromptsInput struct { + ObjectIDs []string `json:"objectIDs"` + DeleteLinkedResponses bool `json:"deleteLinkedResponses,omitempty"` +} + +// ListPromptsResponse represents the response from listing prompts +type ListPromptsResponse struct { + Prompts []Prompt `json:"prompts"` +} + +// GenerateResponseInput represents the input for generating a response +type GenerateResponseInput struct { + Query string `json:"query,omitempty"` + DataSourceID string `json:"dataSourceID"` + PromptID string `json:"promptID"` + LogRegion string `json:"logRegion"` + ObjectID string `json:"objectID,omitempty"` + NbHits int `json:"nbHits,omitempty"` + AdditionalFilters string `json:"additionalFilters,omitempty"` + WithObjectIDs []string `json:"withObjectIDs,omitempty"` + AttributesToRetrieve []string `json:"attributesToRetrieve,omitempty"` + ConversationID string `json:"conversationID,omitempty"` + Save bool `json:"save,omitempty"` + UseCache bool `json:"useCache,omitempty"` + Origin string `json:"origin,omitempty"` +} + +// GenerateResponseOutput represents the output from generating a response +type GenerateResponseOutput struct { + ObjectID string `json:"objectID"` + Response string `json:"response"` + Query string `json:"query"` + DataSourceID string `json:"dataSourceID"` + PromptID string `json:"promptID"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +// ResponseDetails represents a detailed response returned by the get endpoint +type ResponseDetails struct { + Status int `json:"status"` + Query string `json:"query"` + DataSourceID string `json:"dataSourceID"` + PromptID string `json:"promptID"` + AdditionalFilters string `json:"additionalFilters,omitempty"` + Save bool `json:"save"` + UseCache bool `json:"use_cache"` + Origin string `json:"origin"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ObjectID string `json:"objectID"` + Response string `json:"response,omitempty"` +} + +// ListResponsesResponse represents the response from listing responses +type ListResponsesResponse struct { + Responses []GenerateResponseOutput `json:"responses"` +} + +// DeleteResponsesInput represents the input for deleting responses +type DeleteResponsesInput struct { + ObjectIDs []string `json:"objectIDs"` +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 011f0b67..15580bca 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -6,6 +6,7 @@ import ( "github.com/algolia/algoliasearch-client-go/v3/algolia/search" "github.com/algolia/cli/api/crawler" + "github.com/algolia/cli/api/genai" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" @@ -19,6 +20,7 @@ func New(appVersion string, cfg config.IConfig) *cmdutil.Factory { f.IOStreams = ioStreams(f) f.SearchClient = searchClient(f, appVersion) f.CrawlerClient = crawlerClient(f) + f.GenAIClient = genAIClient(f) return f } @@ -63,3 +65,18 @@ func crawlerClient(f *cmdutil.Factory) func() (*crawler.Client, error) { return crawler.NewClient(userID, APIKey), nil } } + +func genAIClient(f *cmdutil.Factory) func() (*genai.Client, error) { + return func() (*genai.Client, error) { + appID, err := f.Config.Profile().GetApplicationID() + if err != nil { + return nil, err + } + APIKey, err := f.Config.Profile().GetAPIKey() + if err != nil { + return nil, err + } + + return genai.NewClient(appID, APIKey), nil + } +} diff --git a/pkg/cmd/genai/datasource/create/create.go b/pkg/cmd/genai/datasource/create/create.go new file mode 100644 index 00000000..1d5166a4 --- /dev/null +++ b/pkg/cmd/genai/datasource/create/create.go @@ -0,0 +1,111 @@ +package create + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type CreateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + Name string + Source string + Filters string + ObjectID string +} + +// NewCreateCmd creates and returns a create command for GenAI data sources. +func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "create --source [--filters ] [--id ]", + Args: cobra.ExactArgs(1), + Short: "Create a GenAI data source", + Long: heredoc.Doc(` + Create a new GenAI data source. + + A data source provides the contexts that the toolkit uses to generate relevant responses to your users' queries. + `), + Example: heredoc.Doc(` + # Create a data source named "Products" using the "products" index + $ algolia genai datasource create Products --source products + + # Create a data source named "Phones" using the "products" index with a filter + $ algolia genai datasource create Phones --source products --filters "category:\"phones\" AND price>500" + + # Create a data source with a specific ID + $ algolia genai datasource create Products --source products --id b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = args[0] + + if opts.Source == "" { + return cmdutil.FlagErrorf("--source is required") + } + + if runF != nil { + return runF(opts) + } + + return runCreateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.Source, "source", "", "The Algolia index to use as the data source") + cmd.Flags().StringVar(&opts.Filters, "filters", "", "Optional filters to apply to the data source") + cmd.Flags().StringVar(&opts.ObjectID, "id", "", "Optional object ID for the data source") + + _ = cmd.MarkFlagRequired("source") + + return cmd +} + +func runCreateCmd(opts *CreateOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Creating data source") + + input := genai.CreateDataSourceInput{ + Name: opts.Name, + Source: opts.Source, + } + + if opts.Filters != "" { + input.Filters = opts.Filters + } + + if opts.ObjectID != "" { + input.ObjectID = opts.ObjectID + } + + response, err := client.CreateDataSource(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Data source %s created with ID: %s\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.Name), cs.Bold(response.ObjectID)) + } + + return nil +} diff --git a/pkg/cmd/genai/datasource/create/create_test.go b/pkg/cmd/genai/datasource/create/create_test.go new file mode 100644 index 00000000..60151637 --- /dev/null +++ b/pkg/cmd/genai/datasource/create/create_test.go @@ -0,0 +1,182 @@ +package create + +import ( + "testing" + + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +func TestNewCreateCmd(t *testing.T) { + tests := []struct { + name string + tty bool + cli string + wantsErr bool + errMsg string + wantsOpts CreateOptions + }{ + { + name: "required source flag", + cli: "MyDataSource", + tty: true, + wantsErr: true, + errMsg: "required flag(s) \"source\" not set", + }, + { + name: "valid with minimum flags", + cli: "MyDataSource --source products", + tty: true, + wantsErr: false, + wantsOpts: CreateOptions{ + Name: "MyDataSource", + Source: "products", + }, + }, + { + name: "valid with all flags", + cli: "MyDataSource --source products --filters \"category:phones\" --id custom-id", + tty: true, + wantsErr: false, + wantsOpts: CreateOptions{ + Name: "MyDataSource", + Source: "products", + Filters: "category:phones", + ObjectID: "custom-id", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + if tt.tty { + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + } + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *CreateOptions + cmd := NewCreateCmd(f, func(o *CreateOptions) error { + opts = o + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, tt.wantsOpts.Name, opts.Name) + assert.Equal(t, tt.wantsOpts.Source, opts.Source) + assert.Equal(t, tt.wantsOpts.Filters, opts.Filters) + assert.Equal(t, tt.wantsOpts.ObjectID, opts.ObjectID) + }) + } +} + +func Test_runCreateCmd(t *testing.T) { + tests := []struct { + name string + opts CreateOptions + isTTY bool + httpStubs func(*httpmock.Registry) + wantOut string + }{ + { + name: "creates with minimum options (tty)", + opts: CreateOptions{ + Name: "MyDataSource", + Source: "products", + }, + isTTY: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "create/data_source"), + httpmock.JSONResponse(genai.DataSourceResponse{ + ObjectID: "ds-123", + }), + ) + }, + wantOut: "✓ Data source MyDataSource created with ID: ds-123\n", + }, + { + name: "creates with all options (tty)", + opts: CreateOptions{ + Name: "MyDataSource", + Source: "products", + Filters: "category:phones", + ObjectID: "custom-id", + }, + isTTY: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "create/data_source"), + httpmock.JSONResponse(genai.DataSourceResponse{ + ObjectID: "custom-id", + }), + ) + }, + wantOut: "✓ Data source MyDataSource created with ID: custom-id\n", + }, + { + name: "creates (non-tty)", + opts: CreateOptions{ + Name: "MyDataSource", + Source: "products", + }, + isTTY: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "create/data_source"), + httpmock.JSONResponse(genai.DataSourceResponse{ + ObjectID: "ds-123", + }), + ) + }, + wantOut: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(&r) + } + defer r.Verify(t) + + f, out := test.NewFactory(tt.isTTY, &r, nil, "") + tt.opts.IO = f.IOStreams + tt.opts.Config = f.Config + tt.opts.GenAIClient = f.GenAIClient + + err := runCreateCmd(&tt.opts) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tt.wantOut, out.String()) + }) + } +} diff --git a/pkg/cmd/genai/datasource/datasource.go b/pkg/cmd/genai/datasource/datasource.go new file mode 100644 index 00000000..242770a2 --- /dev/null +++ b/pkg/cmd/genai/datasource/datasource.go @@ -0,0 +1,35 @@ +package datasource + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/genai/datasource/create" + "github.com/algolia/cli/pkg/cmd/genai/datasource/delete" + "github.com/algolia/cli/pkg/cmd/genai/datasource/get" + "github.com/algolia/cli/pkg/cmd/genai/datasource/list" + "github.com/algolia/cli/pkg/cmd/genai/datasource/update" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewDataSourceCmd returns a new command to manage your Algolia GenAI data sources. +func NewDataSourceCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "datasource", + Aliases: []string{"datasources", "data-sources", "data-source", "ds"}, + Short: "Manage your GenAI data sources", + Long: heredoc.Doc(` + Manage your Algolia GenAI data sources. + + Data sources provide the contexts that the toolkit uses to generate relevant responses to your users' queries. + `), + } + + cmd.AddCommand(create.NewCreateCmd(f, nil)) + cmd.AddCommand(update.NewUpdateCmd(f, nil)) + cmd.AddCommand(delete.NewDeleteCmd(f, nil)) + cmd.AddCommand(list.NewListCmd(f)) + cmd.AddCommand(get.NewGetCmd(f, nil)) + + return cmd +} diff --git a/pkg/cmd/genai/datasource/delete/delete.go b/pkg/cmd/genai/datasource/delete/delete.go new file mode 100644 index 00000000..6f115722 --- /dev/null +++ b/pkg/cmd/genai/datasource/delete/delete.go @@ -0,0 +1,96 @@ +package delete + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type DeleteOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectIDs []string + DeleteLinkedResponses bool +} + +// NewDeleteCmd creates and returns a delete command for GenAI data sources. +func NewDeleteCmd(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "delete ... [--delete-linked-responses]", + Args: cobra.MinimumNArgs(1), + Short: "Delete GenAI data sources", + Long: heredoc.Doc(` + Delete one or more GenAI data sources. + `), + Example: heredoc.Doc(` + # Delete a single data source + $ algolia genai datasource delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 + + # Delete multiple data sources + $ algolia genai datasource delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 b4e52d1a-2509-49ea-ba36-f6f5c3a83ba2 + + # Delete a data source and its linked responses + $ algolia genai datasource delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --delete-linked-responses + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectIDs = args + + if runF != nil { + return runF(opts) + } + + return runDeleteCmd(opts) + }, + } + + cmd.Flags().BoolVar(&opts.DeleteLinkedResponses, "delete-linked-responses", false, "Delete linked responses when deleting the data source") + + return cmd +} + +func runDeleteCmd(opts *DeleteOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Deleting data source(s)") + + input := genai.DeleteDataSourcesInput{ + ObjectIDs: opts.ObjectIDs, + DeleteLinkedResponses: opts.DeleteLinkedResponses, + } + + _, err = client.DeleteDataSources(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + if len(opts.ObjectIDs) == 1 { + fmt.Fprintf(opts.IO.Out, "%s Data source %s deleted\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.ObjectIDs[0])) + } else { + fmt.Fprintf(opts.IO.Out, "%s %d data sources deleted: %s\n", cs.SuccessIconWithColor(cs.Green), len(opts.ObjectIDs), cs.Bold(strings.Join(opts.ObjectIDs, ", "))) + } + } + + return nil +} diff --git a/pkg/cmd/genai/datasource/get/get.go b/pkg/cmd/genai/datasource/get/get.go new file mode 100644 index 00000000..280f4536 --- /dev/null +++ b/pkg/cmd/genai/datasource/get/get.go @@ -0,0 +1,96 @@ +package get + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type GetOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectID string +} + +// NewGetCmd creates and returns a get command for GenAI data sources. +func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "get ", + Args: cobra.ExactArgs(1), + Short: "Get a GenAI data source", + Long: heredoc.Doc(` + Get a specific GenAI data source by ID. + `), + Example: heredoc.Doc(` + # Get a data source by ID + $ algolia genai datasource get b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectID = args[0] + + if runF != nil { + return runF(opts) + } + + return runGetCmd(opts) + }, + } + + return cmd +} + +func runGetCmd(opts *GetOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + opts.IO.StartProgressIndicatorWithLabel("Fetching data source") + + dataSource, err := client.GetDataSource(opts.ObjectID) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("ID:"), cs.Bold(dataSource.ObjectID)) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Name:"), dataSource.Name) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Source:"), dataSource.Source) + if dataSource.Filters != "" { + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Filters:"), dataSource.Filters) + } + fmt.Fprintf(opts.IO.Out, "%s %d\n", cs.Bold("Linked Responses:"), dataSource.LinkedResponses) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Created:"), formatTime(dataSource.CreatedAt)) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Updated:"), formatTime(dataSource.UpdatedAt)) + } else { + fmt.Fprintf(opts.IO.Out, "%s\n", dataSource.ObjectID) + } + + return nil +} + +// formatTime formats time to a readable format +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/pkg/cmd/genai/datasource/list/list.go b/pkg/cmd/genai/datasource/list/list.go new file mode 100644 index 00000000..dfefd7e1 --- /dev/null +++ b/pkg/cmd/genai/datasource/list/list.go @@ -0,0 +1,121 @@ +package list + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/printers" +) + +type ListOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + PrintFlags *cmdutil.PrintFlags +} + +// NewListCmd creates and returns a list command for GenAI data sources. +func NewListCmd(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + PrintFlags: cmdutil.NewPrintFlags(), + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List GenAI data sources", + Long: heredoc.Doc(` + List GenAI data sources. + + Note: This feature is not supported by the Algolia GenAI API yet. + `), + Example: heredoc.Doc(` + # List all data sources + $ algolia genai datasource list + `), + RunE: func(cmd *cobra.Command, args []string) error { + return runListCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + + return cmd +} + +func runListCmd(opts *ListOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + opts.IO.StartProgressIndicatorWithLabel("Fetching data sources") + response, err := client.ListDataSources() + opts.IO.StopProgressIndicator() + + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), err.Error()) + fmt.Fprintln(opts.IO.ErrOut, "") + fmt.Fprintln(opts.IO.ErrOut, "To get a specific data source, you can use:") + fmt.Fprintln(opts.IO.ErrOut, " $ algolia genai datasource get ") + fmt.Fprintln(opts.IO.ErrOut, "") + fmt.Fprintln(opts.IO.ErrOut, "Alternatively, you can access the Algolia dashboard to view your data sources.") + return fmt.Errorf("error fetching data sources") + } + + if opts.PrintFlags.OutputFlagSpecified() { + printer, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + return printer.Print(opts.IO, response) + } + + if len(response.DataSources) == 0 { + fmt.Fprintln(opts.IO.Out, "No data sources found") + return nil + } + + table := printers.NewTablePrinter(opts.IO) + table.AddField("ID", nil, nil) + table.AddField("NAME", nil, nil) + table.AddField("SOURCE", nil, nil) + table.AddField("CREATED", nil, nil) + table.AddField("UPDATED", nil, nil) + table.EndRow() + + for _, ds := range response.DataSources { + createdTime := FormatTime(ds.CreatedAt) + updatedTime := FormatTime(ds.UpdatedAt) + + table.AddField(ds.ID, nil, nil) + table.AddField(ds.Name, nil, nil) + table.AddField(ds.Config.IndexName, nil, nil) + table.AddField(createdTime, nil, nil) + table.AddField(updatedTime, nil, nil) + table.EndRow() + } + + return table.Render() +} + +// FormatTime formats time to a readable format +func FormatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/pkg/cmd/genai/datasource/update/update.go b/pkg/cmd/genai/datasource/update/update.go new file mode 100644 index 00000000..bbbc7b76 --- /dev/null +++ b/pkg/cmd/genai/datasource/update/update.go @@ -0,0 +1,111 @@ +package update + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type UpdateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectID string + Name string + Source string + Filters string +} + +// NewUpdateCmd creates and returns an update command for GenAI data sources. +func NewUpdateCmd(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Command { + opts := &UpdateOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "update [--name ] [--source ] [--filters ]", + Args: cobra.ExactArgs(1), + Short: "Update a GenAI data source", + Long: heredoc.Doc(` + Update an existing GenAI data source. + `), + Example: heredoc.Doc(` + # Update a data source name + $ algolia genai datasource update b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --name "New Products" + + # Update the source index of a data source + $ algolia genai datasource update b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --source new-products-index + + # Update the filters for a data source + $ algolia genai datasource update b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --filters "category:\"new-phones\" AND price>600" + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectID = args[0] + + // At least one flag should be specified + if opts.Name == "" && opts.Source == "" && opts.Filters == "" { + return cmdutil.FlagErrorf("at least one of --name, --source, or --filters must be specified") + } + + if runF != nil { + return runF(opts) + } + + return runUpdateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.Name, "name", "", "New name for the data source") + cmd.Flags().StringVar(&opts.Source, "source", "", "New source index for the data source") + cmd.Flags().StringVar(&opts.Filters, "filters", "", "New filters for the data source") + + return cmd +} + +func runUpdateCmd(opts *UpdateOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Updating data source") + + input := genai.UpdateDataSourceInput{ + ObjectID: opts.ObjectID, + } + + if opts.Name != "" { + input.Name = opts.Name + } + + if opts.Source != "" { + input.Source = opts.Source + } + + if opts.Filters != "" { + input.Filters = opts.Filters + } + + _, err = client.UpdateDataSource(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Data source %s updated\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.ObjectID)) + } + + return nil +} diff --git a/pkg/cmd/genai/genai.go b/pkg/cmd/genai/genai.go new file mode 100644 index 00000000..a25937a2 --- /dev/null +++ b/pkg/cmd/genai/genai.go @@ -0,0 +1,30 @@ +package genai + +import ( + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/genai/datasource" + "github.com/algolia/cli/pkg/cmd/genai/prompt" + "github.com/algolia/cli/pkg/cmd/genai/response" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewGenaiCmd returns a new command to manage your Algolia GenAI Toolkit. +func NewGenaiCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "genai", + Aliases: []string{"genai-toolkit"}, + Short: "Manage your Algolia GenAI Toolkit", + } + + // Add data source commands + cmd.AddCommand(datasource.NewDataSourceCmd(f)) + + // Add prompt commands + cmd.AddCommand(prompt.NewPromptCmd(f)) + + // Add response commands + cmd.AddCommand(response.NewResponseCmd(f)) + + return cmd +} diff --git a/pkg/cmd/genai/prompt/create/create.go b/pkg/cmd/genai/prompt/create/create.go new file mode 100644 index 00000000..6d6091cc --- /dev/null +++ b/pkg/cmd/genai/prompt/create/create.go @@ -0,0 +1,111 @@ +package create + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type CreateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + Name string + Instructions string + Tone string + ObjectID string +} + +// NewCreateCmd creates and returns a create command for GenAI prompts. +func NewCreateCmd(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "create --instructions [--tone ] [--id ]", + Args: cobra.ExactArgs(1), + Short: "Create a GenAI prompt", + Long: heredoc.Doc(` + Create a new GenAI prompt. + + A prompt defines the instructions for how the GenAI toolkit should generate responses. + `), + Example: heredoc.Doc(` + # Create a prompt with instructions + $ algolia genai prompt create "Compare Products" --instructions "Help buyers choose products by comparing features" + + # Create a prompt with a specific tone + $ algolia genai prompt create "Compare Products" --instructions "Help buyers choose products" --tone friendly + + # Create a prompt with a specific ID + $ algolia genai prompt create "Compare Products" --instructions "Help buyers choose products" --id b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = args[0] + + if opts.Instructions == "" { + return cmdutil.FlagErrorf("--instructions is required") + } + + if runF != nil { + return runF(opts) + } + + return runCreateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.Instructions, "instructions", "", "Instructions for the GenAI prompt") + cmd.Flags().StringVar(&opts.Tone, "tone", "", "Tone for the prompt (natural, friendly, or professional)") + cmd.Flags().StringVar(&opts.ObjectID, "id", "", "Optional object ID for the prompt") + + _ = cmd.MarkFlagRequired("instructions") + + return cmd +} + +func runCreateCmd(opts *CreateOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Creating prompt") + + input := genai.CreatePromptInput{ + Name: opts.Name, + Instructions: opts.Instructions, + } + + if opts.Tone != "" { + input.Tone = opts.Tone + } + + if opts.ObjectID != "" { + input.ObjectID = opts.ObjectID + } + + response, err := client.CreatePrompt(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Prompt %s created with ID: %s\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.Name), cs.Bold(response.ObjectID)) + } + + return nil +} diff --git a/pkg/cmd/genai/prompt/delete/delete.go b/pkg/cmd/genai/prompt/delete/delete.go new file mode 100644 index 00000000..53712f66 --- /dev/null +++ b/pkg/cmd/genai/prompt/delete/delete.go @@ -0,0 +1,96 @@ +package delete + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type DeleteOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectIDs []string + DeleteLinkedResponses bool +} + +// NewDeleteCmd creates and returns a delete command for GenAI prompts. +func NewDeleteCmd(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "delete ... [--delete-linked-responses]", + Args: cobra.MinimumNArgs(1), + Short: "Delete GenAI prompts", + Long: heredoc.Doc(` + Delete one or more GenAI prompts. + `), + Example: heredoc.Doc(` + # Delete a single prompt + $ algolia genai prompt delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 + + # Delete multiple prompts + $ algolia genai prompt delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 b4e52d1a-2509-49ea-ba36-f6f5c3a83ba4 + + # Delete a prompt and its linked responses + $ algolia genai prompt delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --delete-linked-responses + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectIDs = args + + if runF != nil { + return runF(opts) + } + + return runDeleteCmd(opts) + }, + } + + cmd.Flags().BoolVar(&opts.DeleteLinkedResponses, "delete-linked-responses", false, "Delete linked responses when deleting the prompt") + + return cmd +} + +func runDeleteCmd(opts *DeleteOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Deleting prompt(s)") + + input := genai.DeletePromptsInput{ + ObjectIDs: opts.ObjectIDs, + DeleteLinkedResponses: opts.DeleteLinkedResponses, + } + + _, err = client.DeletePrompts(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + if len(opts.ObjectIDs) == 1 { + fmt.Fprintf(opts.IO.Out, "%s Prompt %s deleted\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.ObjectIDs[0])) + } else { + fmt.Fprintf(opts.IO.Out, "%s %d prompts deleted: %s\n", cs.SuccessIconWithColor(cs.Green), len(opts.ObjectIDs), cs.Bold(strings.Join(opts.ObjectIDs, ", "))) + } + } + + return nil +} diff --git a/pkg/cmd/genai/prompt/get/get.go b/pkg/cmd/genai/prompt/get/get.go new file mode 100644 index 00000000..d648d0c3 --- /dev/null +++ b/pkg/cmd/genai/prompt/get/get.go @@ -0,0 +1,96 @@ +package get + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type GetOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectID string +} + +// NewGetCmd creates and returns a get command for GenAI prompts. +func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "get ", + Args: cobra.ExactArgs(1), + Short: "Get a GenAI prompt", + Long: heredoc.Doc(` + Get a specific GenAI prompt by ID. + `), + Example: heredoc.Doc(` + # Get a prompt by ID + $ algolia genai prompt get b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectID = args[0] + + if runF != nil { + return runF(opts) + } + + return runGetCmd(opts) + }, + } + + return cmd +} + +func runGetCmd(opts *GetOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + opts.IO.StartProgressIndicatorWithLabel("Fetching prompt") + + prompt, err := client.GetPrompt(opts.ObjectID) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("ID:"), cs.Bold(prompt.ObjectID)) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Name:"), prompt.Name) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Instructions:"), prompt.Instructions) + if prompt.Tone != "" { + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Tone:"), prompt.Tone) + } + fmt.Fprintf(opts.IO.Out, "%s %d\n", cs.Bold("Linked Responses:"), prompt.LinkedResponses) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Created:"), formatTime(prompt.CreatedAt)) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Updated:"), formatTime(prompt.UpdatedAt)) + } else { + fmt.Fprintf(opts.IO.Out, "%s\n", prompt.ObjectID) + } + + return nil +} + +// formatTime formats time to a readable format +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/pkg/cmd/genai/prompt/list/list.go b/pkg/cmd/genai/prompt/list/list.go new file mode 100644 index 00000000..778dc5d4 --- /dev/null +++ b/pkg/cmd/genai/prompt/list/list.go @@ -0,0 +1,119 @@ +package list + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/printers" +) + +type ListOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + PrintFlags *cmdutil.PrintFlags +} + +// NewListCmd creates and returns a list command for GenAI prompts. +func NewListCmd(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + PrintFlags: cmdutil.NewPrintFlags(), + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List GenAI prompts", + Long: heredoc.Doc(` + List GenAI prompts. + + Note: This feature is not supported by the Algolia GenAI API yet. + `), + Example: heredoc.Doc(` + # List all prompts + $ algolia genai prompt list + `), + RunE: func(cmd *cobra.Command, args []string) error { + return runListCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + + return cmd +} + +func runListCmd(opts *ListOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + opts.IO.StartProgressIndicatorWithLabel("Fetching prompts") + response, err := client.ListPrompts() + opts.IO.StopProgressIndicator() + + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), err.Error()) + fmt.Fprintln(opts.IO.ErrOut, "") + fmt.Fprintln(opts.IO.ErrOut, "To get a specific prompt, you can use:") + fmt.Fprintln(opts.IO.ErrOut, " $ algolia genai prompt get ") + fmt.Fprintln(opts.IO.ErrOut, "") + fmt.Fprintln(opts.IO.ErrOut, "Alternatively, you can access the Algolia dashboard to view your prompts.") + return fmt.Errorf("error fetching prompts") + } + + if opts.PrintFlags.OutputFlagSpecified() { + printer, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + return printer.Print(opts.IO, response) + } + + if len(response.Prompts) == 0 { + fmt.Fprintln(opts.IO.Out, "No prompts found") + return nil + } + + table := printers.NewTablePrinter(opts.IO) + table.AddField("ID", nil, nil) + table.AddField("NAME", nil, nil) + table.AddField("CREATED", nil, nil) + table.AddField("UPDATED", nil, nil) + table.EndRow() + + for _, p := range response.Prompts { + createdTime := FormatTime(p.CreatedAt) + updatedTime := FormatTime(p.UpdatedAt) + + table.AddField(p.ID, nil, nil) + table.AddField(p.Name, nil, nil) + table.AddField(createdTime, nil, nil) + table.AddField(updatedTime, nil, nil) + table.EndRow() + } + + return table.Render() +} + +// FormatTime formats time to a readable format +func FormatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/pkg/cmd/genai/prompt/prompt.go b/pkg/cmd/genai/prompt/prompt.go new file mode 100644 index 00000000..25dcd6e4 --- /dev/null +++ b/pkg/cmd/genai/prompt/prompt.go @@ -0,0 +1,35 @@ +package prompt + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/genai/prompt/create" + "github.com/algolia/cli/pkg/cmd/genai/prompt/delete" + "github.com/algolia/cli/pkg/cmd/genai/prompt/get" + "github.com/algolia/cli/pkg/cmd/genai/prompt/list" + "github.com/algolia/cli/pkg/cmd/genai/prompt/update" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewPromptCmd returns a new command to manage your Algolia GenAI prompts. +func NewPromptCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "prompt", + Aliases: []string{"prompts"}, + Short: "Manage your GenAI prompts", + Long: heredoc.Doc(` + Manage your Algolia GenAI prompts. + + Prompts define the instructions for how the GenAI toolkit should generate responses. + `), + } + + cmd.AddCommand(create.NewCreateCmd(f, nil)) + cmd.AddCommand(update.NewUpdateCmd(f, nil)) + cmd.AddCommand(delete.NewDeleteCmd(f, nil)) + cmd.AddCommand(list.NewListCmd(f)) + cmd.AddCommand(get.NewGetCmd(f, nil)) + + return cmd +} diff --git a/pkg/cmd/genai/prompt/update/update.go b/pkg/cmd/genai/prompt/update/update.go new file mode 100644 index 00000000..c8a90d0a --- /dev/null +++ b/pkg/cmd/genai/prompt/update/update.go @@ -0,0 +1,111 @@ +package update + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type UpdateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectID string + Name string + Instructions string + Tone string +} + +// NewUpdateCmd creates and returns an update command for GenAI prompts. +func NewUpdateCmd(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Command { + opts := &UpdateOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "update [--name ] [--instructions ] [--tone ]", + Args: cobra.ExactArgs(1), + Short: "Update a GenAI prompt", + Long: heredoc.Doc(` + Update an existing GenAI prompt. + `), + Example: heredoc.Doc(` + # Update a prompt name + $ algolia genai prompt update b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --name "New Product Comparison" + + # Update a prompt's instructions + $ algolia genai prompt update b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --instructions "New instructions for comparing products" + + # Update a prompt's tone + $ algolia genai prompt update b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --tone professional + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectID = args[0] + + // At least one flag should be specified + if opts.Name == "" && opts.Instructions == "" && opts.Tone == "" { + return cmdutil.FlagErrorf("at least one of --name, --instructions, or --tone must be specified") + } + + if runF != nil { + return runF(opts) + } + + return runUpdateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.Name, "name", "", "New name for the prompt") + cmd.Flags().StringVar(&opts.Instructions, "instructions", "", "New instructions for the prompt") + cmd.Flags().StringVar(&opts.Tone, "tone", "", "New tone for the prompt (natural, friendly, or professional)") + + return cmd +} + +func runUpdateCmd(opts *UpdateOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Updating prompt") + + input := genai.UpdatePromptInput{ + ObjectID: opts.ObjectID, + } + + if opts.Name != "" { + input.Name = opts.Name + } + + if opts.Instructions != "" { + input.Instructions = opts.Instructions + } + + if opts.Tone != "" { + input.Tone = opts.Tone + } + + _, err = client.UpdatePrompt(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Prompt %s updated\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.ObjectID)) + } + + return nil +} diff --git a/pkg/cmd/genai/response/delete/delete.go b/pkg/cmd/genai/response/delete/delete.go new file mode 100644 index 00000000..9f029e9a --- /dev/null +++ b/pkg/cmd/genai/response/delete/delete.go @@ -0,0 +1,89 @@ +package delete + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type DeleteOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectIDs []string +} + +// NewDeleteCmd creates and returns a delete command for GenAI responses. +func NewDeleteCmd(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "delete ...", + Args: cobra.MinimumNArgs(1), + Short: "Delete GenAI responses", + Long: heredoc.Doc(` + Delete one or more GenAI responses. + `), + Example: heredoc.Doc(` + # Delete a single response + $ algolia genai response delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba9 + + # Delete multiple responses + $ algolia genai response delete b4e52d1a-2509-49ea-ba36-f6f5c3a83ba9 b4e52d1a-2509-49ea-ba36-f6f5c3a83ba8 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectIDs = args + + if runF != nil { + return runF(opts) + } + + return runDeleteCmd(opts) + }, + } + + return cmd +} + +func runDeleteCmd(opts *DeleteOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Deleting response(s)") + + input := genai.DeleteResponsesInput{ + ObjectIDs: opts.ObjectIDs, + } + + _, err = client.DeleteResponses(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + if len(opts.ObjectIDs) == 1 { + fmt.Fprintf(opts.IO.Out, "%s Response %s deleted\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(opts.ObjectIDs[0])) + } else { + fmt.Fprintf(opts.IO.Out, "%s %d responses deleted: %s\n", cs.SuccessIconWithColor(cs.Green), len(opts.ObjectIDs), cs.Bold(strings.Join(opts.ObjectIDs, ", "))) + } + } + + return nil +} diff --git a/pkg/cmd/genai/response/generate/generate.go b/pkg/cmd/genai/response/generate/generate.go new file mode 100644 index 00000000..624d9846 --- /dev/null +++ b/pkg/cmd/genai/response/generate/generate.go @@ -0,0 +1,169 @@ +package generate + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type GenerateOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + Query string + DataSourceID string + PromptID string + LogRegion string + ObjectID string + NbHits int + AdditionalFilters string + WithObjectIDs []string + AttributesToRetrieve []string + ConversationID string + NoSave bool + UseCache bool +} + +// NewGenerateCmd creates and returns a generate command for GenAI responses. +func NewGenerateCmd(f *cmdutil.Factory, runF func(*GenerateOptions) error) *cobra.Command { + opts := &GenerateOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + LogRegion: "us", // Default to US region + NbHits: 4, // Default number of hits + } + + var objectIDsString string + + cmd := &cobra.Command{ + Use: "generate --query --datasource --prompt ", + Short: "Generate a GenAI response", + Long: heredoc.Doc(` + Generate a new GenAI response using a prompt and data source. + `), + Example: heredoc.Doc(` + # Generate a response to a query + $ algolia genai response generate --query "Compare iPhone 13 and Samsung S21" --datasource b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --prompt b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 + + # Generate a response with additional filters + $ algolia genai response generate --query "Compare phones" --datasource b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --prompt b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --filters 'model:"iPhone 13" OR model:"Samsung S21"' + + # Generate a response without saving it + $ algolia genai response generate --query "Compare phones" --datasource b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --prompt b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --no-save + + # Generate a response using specific object IDs instead of search + $ algolia genai response generate --query "Compare these products" --datasource b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --prompt b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --object-ids "product1,product2,product3" + + # Generate a response for a conversation + $ algolia genai response generate --query "Tell me more about the second one" --datasource b4e52d1a-2509-49ea-ba36-f6f5c3a83ba1 --prompt b4e52d1a-2509-49ea-ba36-f6f5c3a83ba3 --conversation-id conv123 + `), + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Query == "" { + return cmdutil.FlagErrorf("--query is required") + } + + if opts.DataSourceID == "" { + return cmdutil.FlagErrorf("--datasource is required") + } + + if opts.PromptID == "" { + return cmdutil.FlagErrorf("--prompt is required") + } + + // Parse object IDs if provided + if objectIDsString != "" { + opts.WithObjectIDs = strings.Split(objectIDsString, ",") + } + + if runF != nil { + return runF(opts) + } + + return runGenerateCmd(opts) + }, + } + + cmd.Flags().StringVar(&opts.Query, "query", "", "The query to generate a response for") + cmd.Flags().StringVar(&opts.DataSourceID, "datasource", "", "The ID of the data source to use") + cmd.Flags().StringVar(&opts.PromptID, "prompt", "", "The ID of the prompt to use") + cmd.Flags().StringVar(&opts.LogRegion, "region", "us", "The region to use for LLM routing (us or de)") + cmd.Flags().StringVar(&opts.ObjectID, "id", "", "Optional object ID for the response") + cmd.Flags().IntVar(&opts.NbHits, "hits", 4, "Number of hits to retrieve as context") + cmd.Flags().StringVar(&opts.AdditionalFilters, "filters", "", "Additional filters to apply") + cmd.Flags().StringVar(&objectIDsString, "object-ids", "", "Specific object IDs to use instead of search (comma-separated)") + cmd.Flags().StringSliceVar(&opts.AttributesToRetrieve, "attributes", nil, "Specific attributes to retrieve from the hits") + cmd.Flags().StringVar(&opts.ConversationID, "conversation-id", "", "Conversation ID for follow-up queries") + cmd.Flags().BoolVar(&opts.NoSave, "no-save", false, "Don't save the response") + cmd.Flags().BoolVar(&opts.UseCache, "use-cache", false, "Use cached response if available") + + _ = cmd.MarkFlagRequired("query") + _ = cmd.MarkFlagRequired("datasource") + _ = cmd.MarkFlagRequired("prompt") + + return cmd +} + +func runGenerateCmd(opts *GenerateOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Generating response") + + input := genai.GenerateResponseInput{ + Query: opts.Query, + DataSourceID: opts.DataSourceID, + PromptID: opts.PromptID, + LogRegion: opts.LogRegion, + NbHits: opts.NbHits, + Save: !opts.NoSave, + UseCache: opts.UseCache, + } + + if opts.ObjectID != "" { + input.ObjectID = opts.ObjectID + } + + if opts.AdditionalFilters != "" { + input.AdditionalFilters = opts.AdditionalFilters + } + + if len(opts.WithObjectIDs) > 0 { + input.WithObjectIDs = opts.WithObjectIDs + } + + if len(opts.AttributesToRetrieve) > 0 { + input.AttributesToRetrieve = opts.AttributesToRetrieve + } + + if opts.ConversationID != "" { + input.ConversationID = opts.ConversationID + } + + response, err := client.GenerateResponse(input) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Response generated with ID: %s\n\n", cs.SuccessIconWithColor(cs.Green), cs.Bold(response.ObjectID)) + fmt.Fprintf(opts.IO.Out, "%s\n", response.Response) + } else { + fmt.Fprintf(opts.IO.Out, "%s", response.Response) + } + + return nil +} diff --git a/pkg/cmd/genai/response/generate/generate_test.go b/pkg/cmd/genai/response/generate/generate_test.go new file mode 100644 index 00000000..6696b0b7 --- /dev/null +++ b/pkg/cmd/genai/response/generate/generate_test.go @@ -0,0 +1,241 @@ +package generate + +import ( + "testing" + + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/httpmock" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +func TestNewGenerateCmd(t *testing.T) { + tests := []struct { + name string + tty bool + cli string + wantsErr bool + errMsg string + wantsOpts GenerateOptions + }{ + { + name: "missing required flags", + cli: "", + tty: true, + wantsErr: true, + errMsg: "required flag(s) \"datasource\", \"prompt\", \"query\" not set", + }, + { + name: "missing datasource", + cli: "--query \"hello\" --prompt prompt-123", + tty: true, + wantsErr: true, + errMsg: "required flag(s) \"datasource\" not set", + }, + { + name: "missing prompt", + cli: "--query \"hello\" --datasource ds-123", + tty: true, + wantsErr: true, + errMsg: "required flag(s) \"prompt\" not set", + }, + { + name: "missing query", + cli: "--datasource ds-123 --prompt prompt-123", + tty: true, + wantsErr: true, + errMsg: "required flag(s) \"query\" not set", + }, + { + name: "valid with minimum flags", + cli: "--query \"hello\" --datasource ds-123 --prompt prompt-123", + tty: true, + wantsErr: false, + wantsOpts: GenerateOptions{ + Query: "hello", + DataSourceID: "ds-123", + PromptID: "prompt-123", + LogRegion: "us", + NbHits: 4, + }, + }, + { + name: "valid with all flags", + cli: "--query \"hello\" --datasource ds-123 --prompt prompt-123 --region de --id resp-123 --hits 10 --filters \"brand:apple\" --object-ids id1,id2 --attributes name,price --conversation-id conv-123 --no-save --use-cache", + tty: true, + wantsErr: false, + wantsOpts: GenerateOptions{ + Query: "hello", + DataSourceID: "ds-123", + PromptID: "prompt-123", + LogRegion: "de", + ObjectID: "resp-123", + NbHits: 10, + AdditionalFilters: "brand:apple", + WithObjectIDs: []string{"id1", "id2"}, + AttributesToRetrieve: []string{"name", "price"}, + ConversationID: "conv-123", + NoSave: true, + UseCache: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + if tt.tty { + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + } + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *GenerateOptions + cmd := NewGenerateCmd(f, func(o *GenerateOptions) error { + opts = o + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, tt.wantsOpts.Query, opts.Query) + assert.Equal(t, tt.wantsOpts.DataSourceID, opts.DataSourceID) + assert.Equal(t, tt.wantsOpts.PromptID, opts.PromptID) + assert.Equal(t, tt.wantsOpts.LogRegion, opts.LogRegion) + assert.Equal(t, tt.wantsOpts.ObjectID, opts.ObjectID) + assert.Equal(t, tt.wantsOpts.NbHits, opts.NbHits) + assert.Equal(t, tt.wantsOpts.AdditionalFilters, opts.AdditionalFilters) + assert.Equal(t, tt.wantsOpts.WithObjectIDs, opts.WithObjectIDs) + assert.Equal(t, tt.wantsOpts.AttributesToRetrieve, opts.AttributesToRetrieve) + assert.Equal(t, tt.wantsOpts.ConversationID, opts.ConversationID) + assert.Equal(t, tt.wantsOpts.NoSave, opts.NoSave) + assert.Equal(t, tt.wantsOpts.UseCache, opts.UseCache) + }) + } +} + +func Test_runGenerateCmd(t *testing.T) { + tests := []struct { + name string + opts GenerateOptions + isTTY bool + httpStubs func(*httpmock.Registry) + wantOut string + }{ + { + name: "generates response (tty)", + opts: GenerateOptions{ + Query: "hello", + DataSourceID: "ds-123", + PromptID: "prompt-123", + LogRegion: "us", + NbHits: 4, + }, + isTTY: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "generate/response"), + httpmock.JSONResponse(genai.GenerateResponseOutput{ + ObjectID: "resp-123", + Response: "Hello! How can I help you today?", + }), + ) + }, + wantOut: "✓ Response generated with ID: resp-123\n\nHello! How can I help you today?\n", + }, + { + name: "generates response (non-tty)", + opts: GenerateOptions{ + Query: "hello", + DataSourceID: "ds-123", + PromptID: "prompt-123", + LogRegion: "us", + NbHits: 4, + }, + isTTY: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "generate/response"), + httpmock.JSONResponse(genai.GenerateResponseOutput{ + ObjectID: "resp-123", + Response: "Hello! How can I help you today?", + }), + ) + }, + wantOut: "Hello! How can I help you today?", + }, + { + name: "generates response with all options", + opts: GenerateOptions{ + Query: "hello", + DataSourceID: "ds-123", + PromptID: "prompt-123", + LogRegion: "de", + ObjectID: "resp-123", + NbHits: 10, + AdditionalFilters: "brand:apple", + WithObjectIDs: []string{"id1", "id2"}, + AttributesToRetrieve: []string{"name", "price"}, + ConversationID: "conv-123", + NoSave: true, + UseCache: true, + }, + isTTY: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "generate/response"), + httpmock.JSONResponse(genai.GenerateResponseOutput{ + ObjectID: "resp-123", + Response: "Here is information about the apple products you requested.", + }), + ) + }, + wantOut: "✓ Response generated with ID: resp-123\n\nHere is information about the apple products you requested.\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(&r) + } + defer r.Verify(t) + + f, out := test.NewFactory(tt.isTTY, &r, nil, "") + tt.opts.IO = f.IOStreams + tt.opts.Config = f.Config + tt.opts.GenAIClient = f.GenAIClient + + err := runGenerateCmd(&tt.opts) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, tt.wantOut, out.String()) + }) + } +} diff --git a/pkg/cmd/genai/response/get/get.go b/pkg/cmd/genai/response/get/get.go new file mode 100644 index 00000000..3ae98153 --- /dev/null +++ b/pkg/cmd/genai/response/get/get.go @@ -0,0 +1,105 @@ +package get + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" +) + +type GetOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + ObjectID string +} + +// NewGetCmd creates and returns a get command for GenAI responses. +func NewGetCmd(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + } + + cmd := &cobra.Command{ + Use: "get ", + Args: cobra.ExactArgs(1), + Short: "Get a GenAI response", + Long: heredoc.Doc(` + Get a GenAI response by ID. + `), + Example: heredoc.Doc(` + # Get a response by ID + $ algolia genai response get b4e52d1a-2509-49ea-ba36-f6f5c3a83ba9 + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.ObjectID = args[0] + + if runF != nil { + return runF(opts) + } + + return runGetCmd(opts) + }, + } + + return cmd +} + +func runGetCmd(opts *GetOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Getting response") + + response, err := client.GetResponse(opts.ObjectID) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("ID:"), cs.Bold(response.ObjectID)) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Query:"), response.Query) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Data Source:"), response.DataSourceID) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Prompt:"), response.PromptID) + + if response.AdditionalFilters != "" { + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Additional Filters:"), response.AdditionalFilters) + } + + fmt.Fprintf(opts.IO.Out, "%s %t\n", cs.Bold("Save:"), response.Save) + fmt.Fprintf(opts.IO.Out, "%s %t\n", cs.Bold("Use Cache:"), response.UseCache) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Origin:"), response.Origin) + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.Bold("Created:"), formatTime(response.CreatedAt)) + fmt.Fprintf(opts.IO.Out, "%s %s\n\n", cs.Bold("Updated:"), formatTime(response.UpdatedAt)) + + if response.Response != "" { + fmt.Fprintf(opts.IO.Out, "%s\n", response.Response) + } + } else { + fmt.Fprintf(opts.IO.Out, "%s", response.Response) + } + + return nil +} + +// formatTime formats time to a readable format +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} diff --git a/pkg/cmd/genai/response/list/list.go b/pkg/cmd/genai/response/list/list.go new file mode 100644 index 00000000..ea4f6842 --- /dev/null +++ b/pkg/cmd/genai/response/list/list.go @@ -0,0 +1,128 @@ +package list + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/genai" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/printers" +) + +type ListOptions struct { + Config config.IConfig + IO *iostreams.IOStreams + + GenAIClient func() (*genai.Client, error) + + PrintFlags *cmdutil.PrintFlags +} + +// NewListCmd creates and returns a list command for GenAI responses. +func NewListCmd(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + GenAIClient: f.GenAIClient, + PrintFlags: cmdutil.NewPrintFlags(), + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List GenAI responses", + Long: heredoc.Doc(` + List GenAI responses. + + Note: This feature might not be supported by the Algolia GenAI API yet. + `), + Example: heredoc.Doc(` + # List all responses + $ algolia genai response list + `), + RunE: func(cmd *cobra.Command, args []string) error { + return runListCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + + return cmd +} + +func runListCmd(opts *ListOptions) error { + client, err := opts.GenAIClient() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + opts.IO.StartProgressIndicatorWithLabel("Fetching responses") + response, err := client.ListResponses() + opts.IO.StopProgressIndicator() + + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), err.Error()) + fmt.Fprintln(opts.IO.ErrOut, "") + fmt.Fprintln(opts.IO.ErrOut, "To get a specific response, you can use:") + fmt.Fprintln(opts.IO.ErrOut, " $ algolia genai response get ") + fmt.Fprintln(opts.IO.ErrOut, "") + fmt.Fprintln(opts.IO.ErrOut, "Alternatively, you can access the Algolia dashboard to view your responses.") + return fmt.Errorf("error fetching responses") + } + + if opts.PrintFlags.OutputFlagSpecified() { + printer, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + return printer.Print(opts.IO, response) + } + + if len(response.Responses) == 0 { + fmt.Fprintln(opts.IO.Out, "No responses found") + return nil + } + + table := printers.NewTablePrinter(opts.IO) + table.AddField("ID", nil, nil) + table.AddField("QUERY", nil, nil) + table.AddField("DATA SOURCE", nil, nil) + table.AddField("PROMPT", nil, nil) + table.AddField("CREATED", nil, nil) + table.EndRow() + + for _, r := range response.Responses { + createdTime := FormatTime(r.CreatedAt) + + table.AddField(r.ObjectID, nil, nil) + table.AddField(truncateString(r.Query, 30), nil, nil) + table.AddField(r.DataSourceID, nil, nil) + table.AddField(r.PromptID, nil, nil) + table.AddField(createdTime, nil, nil) + table.EndRow() + } + + return table.Render() +} + +// FormatTime formats time to a readable format +func FormatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02 15:04:05") +} + +// truncateString truncates a string if it's longer than maxLen +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/pkg/cmd/genai/response/response.go b/pkg/cmd/genai/response/response.go new file mode 100644 index 00000000..1b9b7f06 --- /dev/null +++ b/pkg/cmd/genai/response/response.go @@ -0,0 +1,33 @@ +package response + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/pkg/cmd/genai/response/delete" + "github.com/algolia/cli/pkg/cmd/genai/response/generate" + "github.com/algolia/cli/pkg/cmd/genai/response/get" + "github.com/algolia/cli/pkg/cmd/genai/response/list" + "github.com/algolia/cli/pkg/cmdutil" +) + +// NewResponseCmd returns a new command to manage your Algolia GenAI responses. +func NewResponseCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "response", + Aliases: []string{"responses"}, + Short: "Manage your GenAI responses", + Long: heredoc.Doc(` + Manage your Algolia GenAI responses. + + Responses are generated using your prompts and data sources to answer your queries. + `), + } + + cmd.AddCommand(generate.NewGenerateCmd(f, nil)) + cmd.AddCommand(get.NewGetCmd(f, nil)) + cmd.AddCommand(delete.NewDeleteCmd(f, nil)) + cmd.AddCommand(list.NewListCmd(f)) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 10675166..1af871c9 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -27,6 +27,7 @@ import ( "github.com/algolia/cli/pkg/cmd/dictionary" "github.com/algolia/cli/pkg/cmd/events" "github.com/algolia/cli/pkg/cmd/factory" + "github.com/algolia/cli/pkg/cmd/genai" "github.com/algolia/cli/pkg/cmd/indices" "github.com/algolia/cli/pkg/cmd/objects" "github.com/algolia/cli/pkg/cmd/open" @@ -106,6 +107,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(dictionary.NewDictionaryCmd(f)) cmd.AddCommand(events.NewEventsCmd(f)) cmd.AddCommand(crawler.NewCrawlersCmd(f)) + cmd.AddCommand(genai.NewGenaiCmd(f)) // ??? related commands cmd.AddCommand(art.NewArtCmd(f)) diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 93fa1e93..1908a3a7 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -8,6 +8,7 @@ import ( "github.com/algolia/algoliasearch-client-go/v3/algolia/search" "github.com/algolia/cli/api/crawler" + "github.com/algolia/cli/api/genai" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" ) @@ -17,6 +18,7 @@ type Factory struct { Config config.IConfig SearchClient func() (*search.Client, error) CrawlerClient func() (*crawler.Client, error) + GenAIClient func() (*genai.Client, error) ExecutableName string } diff --git a/test/helpers.go b/test/helpers.go index 98580726..14b72bab 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -10,6 +10,7 @@ import ( "github.com/algolia/algoliasearch-client-go/v3/algolia/search" "github.com/algolia/cli/api/crawler" + "github.com/algolia/cli/api/genai" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/httpmock" @@ -74,6 +75,11 @@ func NewFactory(isTTY bool, r *httpmock.Registry, cfg config.IConfig, in string) Transport: r, }), nil } + f.GenAIClient = func() (*genai.Client, error) { + return genai.NewClientWithHTTPClient("id", "key", &http.Client{ + Transport: r, + }), nil + } } if cfg != nil { From 2c09495961676ce19bd54459fbb5caca1cba8850 Mon Sep 17 00:00:00 2001 From: Dylan Tientcheu Date: Mon, 10 Mar 2025 22:34:30 +0100 Subject: [PATCH 2/2] fix: default response generation to no save --- pkg/cmd/genai/response/generate/generate.go | 6 +++--- pkg/cmd/genai/response/generate/generate_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/genai/response/generate/generate.go b/pkg/cmd/genai/response/generate/generate.go index 624d9846..50e8150d 100644 --- a/pkg/cmd/genai/response/generate/generate.go +++ b/pkg/cmd/genai/response/generate/generate.go @@ -29,7 +29,7 @@ type GenerateOptions struct { WithObjectIDs []string AttributesToRetrieve []string ConversationID string - NoSave bool + Save bool UseCache bool } @@ -103,7 +103,7 @@ func NewGenerateCmd(f *cmdutil.Factory, runF func(*GenerateOptions) error) *cobr cmd.Flags().StringVar(&objectIDsString, "object-ids", "", "Specific object IDs to use instead of search (comma-separated)") cmd.Flags().StringSliceVar(&opts.AttributesToRetrieve, "attributes", nil, "Specific attributes to retrieve from the hits") cmd.Flags().StringVar(&opts.ConversationID, "conversation-id", "", "Conversation ID for follow-up queries") - cmd.Flags().BoolVar(&opts.NoSave, "no-save", false, "Don't save the response") + cmd.Flags().BoolVar(&opts.Save, "save", false, "Save the response") cmd.Flags().BoolVar(&opts.UseCache, "use-cache", false, "Use cached response if available") _ = cmd.MarkFlagRequired("query") @@ -128,7 +128,7 @@ func runGenerateCmd(opts *GenerateOptions) error { PromptID: opts.PromptID, LogRegion: opts.LogRegion, NbHits: opts.NbHits, - Save: !opts.NoSave, + Save: opts.Save, UseCache: opts.UseCache, } diff --git a/pkg/cmd/genai/response/generate/generate_test.go b/pkg/cmd/genai/response/generate/generate_test.go index 6696b0b7..8969dddc 100644 --- a/pkg/cmd/genai/response/generate/generate_test.go +++ b/pkg/cmd/genai/response/generate/generate_test.go @@ -66,7 +66,7 @@ func TestNewGenerateCmd(t *testing.T) { }, { name: "valid with all flags", - cli: "--query \"hello\" --datasource ds-123 --prompt prompt-123 --region de --id resp-123 --hits 10 --filters \"brand:apple\" --object-ids id1,id2 --attributes name,price --conversation-id conv-123 --no-save --use-cache", + cli: "--query \"hello\" --datasource ds-123 --prompt prompt-123 --region de --id resp-123 --hits 10 --filters \"brand:apple\" --object-ids id1,id2 --attributes name,price --conversation-id conv-123 --save --use-cache", tty: true, wantsErr: false, wantsOpts: GenerateOptions{ @@ -80,7 +80,7 @@ func TestNewGenerateCmd(t *testing.T) { WithObjectIDs: []string{"id1", "id2"}, AttributesToRetrieve: []string{"name", "price"}, ConversationID: "conv-123", - NoSave: true, + Save: true, UseCache: true, }, }, @@ -131,7 +131,7 @@ func TestNewGenerateCmd(t *testing.T) { assert.Equal(t, tt.wantsOpts.WithObjectIDs, opts.WithObjectIDs) assert.Equal(t, tt.wantsOpts.AttributesToRetrieve, opts.AttributesToRetrieve) assert.Equal(t, tt.wantsOpts.ConversationID, opts.ConversationID) - assert.Equal(t, tt.wantsOpts.NoSave, opts.NoSave) + assert.Equal(t, tt.wantsOpts.Save, opts.Save) assert.Equal(t, tt.wantsOpts.UseCache, opts.UseCache) }) } @@ -200,7 +200,7 @@ func Test_runGenerateCmd(t *testing.T) { WithObjectIDs: []string{"id1", "id2"}, AttributesToRetrieve: []string{"name", "price"}, ConversationID: "conv-123", - NoSave: true, + Save: true, UseCache: true, }, isTTY: true,