diff --git a/go/ai/generator.go b/go/ai/generator.go index 748b79676e..984d0db6ca 100644 --- a/go/ai/generator.go +++ b/go/ai/generator.go @@ -159,7 +159,7 @@ func (ga *generatorAction) Generate(ctx context.Context, req *GenerateRequest, c // conformOutput appends a message to the request indicating conformance to the expected schema. func conformOutput(req *GenerateRequest) error { - if req.Output.Format == OutputFormatJSON && len(req.Messages) > 0 { + if req.Output != nil && req.Output.Format == OutputFormatJSON && len(req.Messages) > 0 { jsonBytes, err := json.Marshal(req.Output.Schema) if err != nil { return fmt.Errorf("expected schema is not valid: %w", err) @@ -193,7 +193,7 @@ func validCandidates(ctx context.Context, resp *GenerateResponse) ([]*Candidate, // validCandidate will validate the candidate's response against the expected schema. // It will return an error if it does not match, otherwise it will return a candidate with JSON content and type. func validCandidate(c *Candidate, output *GenerateRequestOutput) (*Candidate, error) { - if output.Format == OutputFormatJSON { + if output != nil && output.Format == OutputFormatJSON { text, err := c.Text() if err != nil { return nil, err diff --git a/go/samples/menu/main.go b/go/samples/menu/main.go new file mode 100644 index 0000000000..34691c1186 --- /dev/null +++ b/go/samples/menu/main.go @@ -0,0 +1,123 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/localvec" + "github.com/firebase/genkit/go/plugins/vertexai" + "github.com/invopop/jsonschema" +) + +const geminiPro = "gemini-1.0-pro" + +// menuItem is the data model for an item on the menu. +type menuItem struct { + Title string `json:"title" jsonschema_description:"The name of the menu item"` + Description string `json:"description" jsonschema_description:"Details including ingredients and preparation"` + Price float64 `json:"price" jsonschema_description:"Price in dollars"` +} + +// menuQuestionInput is a question about the menu. +type menuQuestionInput struct { + Question string `json:"question"` +} + +// menuQuestionInputSchema is the JSON schema for a menuQuestionInput. +var menuQuestionInputSchema = jsonschema.Reflect(menuQuestionInput{}) + +// answerOutput is an answer to a question. +type answerOutput struct { + Answer string `json:"answer"` +} + +// dataMenuQuestionInput is a question about the menu, +// where the menu is provided in the JSON data. +type dataMenuQuestionInput struct { + MenuData []*menuItem `json:"menuData"` + Question string `json:"question"` +} + +// dataMenuQuestionInputSchema is the JSON schema for a dataMenuQuestionInput. +var dataMenuQuestionInputSchema = jsonschema.Reflect(dataMenuQuestionInput{}) + +// textMenuQuestionInput is for a question about the menu, +// where the menu is provided as unstructured text. +type textMenuQuestionInput struct { + MenuText string `json:"menuText"` + Question string `json:"question"` +} + +// textMenuQuestionInputSchema is the JSON schema for a textMenuQuestionInput. +var textMenuQuestionInputSchema = jsonschema.Reflect(textMenuQuestionInput{}) + +func main() { + projectID := os.Getenv("GCLOUD_PROJECT") + if projectID == "" { + fmt.Fprintln(os.Stderr, "menu example requires setting GCLOUD_PROJECT in the environment.") + os.Exit(1) + } + + location := "us-central1" + if envLocation := os.Getenv("GCLOUD_LOCATION"); envLocation != "" { + location = envLocation + } + + if err := vertexai.Init(context.Background(), geminiPro, projectID, location); err != nil { + log.Fatal(err) + } + + ctx := context.Background() + if err := setup01(ctx); err != nil { + log.Fatal(err) + } + if err := setup02(ctx); err != nil { + log.Fatal(err) + } + + generator, err := ai.LookupGeneratorAction("google-vertexai", geminiPro) + if err != nil { + log.Fatal(err) + } + if err := setup03(ctx, generator); err != nil { + log.Fatal(err) + } + + embedder, err := vertexai.NewEmbedder(ctx, "textembedding-gecko", projectID, location) + if err != nil { + log.Fatal(err) + } + ds, err := localvec.New(ctx, os.TempDir(), "go-menu-items", embedder, nil) + if err != nil { + log.Fatal(err) + } + if err := setup04(ctx, ds); err != nil { + log.Fatal(err) + } + + if err := setup05(ctx); err != nil { + log.Fatal(err) + } + + if err := genkit.StartFlowServer(""); err != nil { + log.Fatal(err) + } +} diff --git a/go/samples/menu/s01.go b/go/samples/menu/s01.go new file mode 100644 index 0000000000..a8d766fe43 --- /dev/null +++ b/go/samples/menu/s01.go @@ -0,0 +1,80 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/plugins/dotprompt" +) + +func setup01(ctx context.Context) error { + _, err := dotprompt.Define("s01_vanillaPrompt", + `You are acting as a helpful AI assistant named "Walt" that can answer + questions about the food available on the menu at Walt's Burgers. + Customer says: ${input.question}`, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro", + InputSchema: menuQuestionInputSchema, + }, + ) + if err != nil { + return err + } + + _, err = dotprompt.Define("s01_staticMenuDotPrompt", + `You are acting as a helpful AI assistant named "Walt" that can answer + questions about the food available on the menu at Walt's Burgers. + Here is today's menu: + + - The Regular Burger $12 + The classic charbroiled to perfection with your choice of cheese + + - The Fancy Burger $13 + Classic burger topped with bacon & Blue Cheese + + - The Bacon Burger $13 + Bacon cheeseburger with your choice of cheese. + + - Everything Burger $14 + Heinz 57 sauce, American cheese, bacon, fried egg & crispy onion bits + + - Chicken Breast Sandwich $12 + Tender juicy chicken breast on a brioche roll. + Grilled, blackened, or fried + + Our fresh 1/2 lb. beef patties are made using choice cut + brisket, short rib & sirloin. Served on a toasted + brioche roll with chips. Served with lettuce, tomato & pickles. + Onions upon request. Substitute veggie patty $2 + + Answer this customer's question, in a concise and helpful manner, + as long as it is about food. + + Question: + {{question}} ?`, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro", + InputSchema: menuQuestionInputSchema, + OutputFormat: ai.OutputFormatText, + }, + ) + if err != nil { + return err + } + + return nil +} diff --git a/go/samples/menu/s02.go b/go/samples/menu/s02.go new file mode 100644 index 0000000000..a79ede0dd0 --- /dev/null +++ b/go/samples/menu/s02.go @@ -0,0 +1,97 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/dotprompt" +) + +var menuToolDef = &ai.ToolDefinition{ + Name: "todaysMenu", + OutputSchema: map[string]any{ + "menuData": []menuItem{}, + }, + Description: "Use this tool to retrieve all the items on today's menu", +} + +func menu(ctx context.Context, input map[string]any) (map[string]any, error) { + f, err := os.Open("testdata/menu.json") + if err != nil { + return nil, err + } + decoder := json.NewDecoder(f) + var s []any + if err := decoder.Decode(&s); err != nil { + return nil, err + } + return map[string]any{"menu": s}, nil +} + +func setup02(ctx context.Context) error { + ai.RegisterTool("menu", menuToolDef, nil, menu) + + dataMenuPrompt, err := dotprompt.Define("s02_dataMenu", + `You are acting as a helpful AI assistant named Walt that can answer + questions about the food available on the menu at Walt's Burgers. + + Answer this customer's question, in a concise and helpful manner, + as long as it is about food on the menu or something harmless like sports. + Use the tools available to answer menu questions. + DO NOT INVENT ITEMS NOT ON THE MENU. + + Question: + {{question}} ?`, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro", + InputSchema: menuQuestionInputSchema, + OutputFormat: ai.OutputFormatText, + Tools: []*ai.ToolDefinition{ + menuToolDef, + }, + }, + ) + if err != nil { + return err + } + + genkit.DefineFlow("s02_menuQuestion", + func(ctx context.Context, input *menuQuestionInput, _ genkit.NoStream) (*answerOutput, error) { + resp, err := dataMenuPrompt.Generate(ctx, + &ai.PromptRequest{ + Variables: input, + }, + nil, + ) + if err != nil { + return nil, err + } + + text, err := resp.Text() + if err != nil { + return nil, fmt.Errorf("s02MenuQuestionFlow: %v", err) + } + return &answerOutput{Answer: text}, nil + }, + ) + + return nil +} diff --git a/go/samples/menu/s03.go b/go/samples/menu/s03.go new file mode 100644 index 0000000000..26d9663e39 --- /dev/null +++ b/go/samples/menu/s03.go @@ -0,0 +1,135 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "slices" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/dotprompt" +) + +type chatSessionInput struct { + SessionID string `json:"sessionID"` + Question string `json:"question"` +} + +type chatSessionOutput struct { + SessionID string `json:"sessionID"` + History []*ai.Message `json:"history"` +} + +// Very simple local storage for chat history. +// Each conversation is identified by a sessionID generated by the application. +// The history has a preamble of message, which serves as a system prompt. + +type chatHistory []*ai.Message + +type chatHistoryStore struct { + preamble chatHistory + sessions map[string]chatHistory +} + +func (ch *chatHistoryStore) Store(sessionID string, history chatHistory) { + ch.sessions[sessionID] = history +} + +func (ch *chatHistoryStore) Retrieve(sessionID string) chatHistory { + if h, ok := ch.sessions[sessionID]; ok { + return h + } + return ch.preamble +} + +func setup03(ctx context.Context, generator ai.Generator) error { + chatPreamblePrompt, err := dotprompt.Define("s03_chatPreamble", + ` + {{ role "user" }} + Hi. What's on the menu today? + + {{ role "model" }} + I am Walt, a helpful AI assistant here at the restaurant. + I can answer questions about the food on the menu or any other questions + you have about food in general. I probably can't help you with anything else. + Here is today's menu: + {{#each menuData~}} + - {{this.title}} \${{this.price}} + {{this.description}} + {{~/each}} + Do you have any questions about the menu?`, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro", + InputSchema: dataMenuQuestionInputSchema, + OutputFormat: ai.OutputFormatText, + GenerationConfig: &ai.GenerationCommonConfig{ + Temperature: 0.3, + }, + }, + ) + if err != nil { + return err + } + + menuData, err := menu(context.Background(), nil) + if err != nil { + return err + } + + preamble, err := chatPreamblePrompt.RenderMessages(map[string]any{ + "menuData": menuData, + "question": "", + }) + if err != nil { + return err + } + + storedHistory := &chatHistoryStore{ + preamble: chatHistory(preamble), + sessions: make(map[string]chatHistory), + } + + genkit.DefineFlow("s03_multiTurnChat", + func(ctx context.Context, input *chatSessionInput, _ genkit.NoStream) (*chatSessionOutput, error) { + history := storedHistory.Retrieve(input.SessionID) + msg := &ai.Message{ + Content: []*ai.Part{ + ai.NewTextPart(input.Question), + }, + Role: ai.RoleUser, + } + messages := append(slices.Clip(history), msg) + req := &ai.GenerateRequest{ + Messages: messages, + } + resp, err := generator.Generate(ctx, req, nil) + if err != nil { + return nil, err + } + + messages = append(messages, resp.Candidates[0].Message) + storedHistory.Store(input.SessionID, messages) + + out := &chatSessionOutput{ + SessionID: input.SessionID, + History: messages, + } + return out, nil + }, + ) + + return nil +} diff --git a/go/samples/menu/s04.go b/go/samples/menu/s04.go new file mode 100644 index 0000000000..93e1dd6545 --- /dev/null +++ b/go/samples/menu/s04.go @@ -0,0 +1,122 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/dotprompt" + "github.com/firebase/genkit/go/plugins/localvec" +) + +func setup04(ctx context.Context, datastore ai.Retriever) error { + ragDataMenuPrompt, err := dotprompt.Define("s04_ragDataMenu", + ` + You are acting as Walt, a helpful AI assistant here at the restaurant. + You can answer questions about the food on the menu or any other questions + customers have about food in general. + + Here are some items that are on today's menu that are relevant to + helping you answer the customer's question: + {{#each menuData~}} + - {{this.title}} \${{this.price}} + {{this.description}} + {{~/each}} + + Answer this customer's question: + {{question}}?`, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro", + InputSchema: dataMenuQuestionInputSchema, + OutputFormat: ai.OutputFormatText, + GenerationConfig: &ai.GenerationCommonConfig{ + Temperature: 0.3, + }, + }, + ) + if err != nil { + return err + } + + type flowOutput struct { + Rows int `json:"rows"` + } + + genkit.DefineFlow("s04_indexMenuItems", + func(ctx context.Context, input []*menuItem, _ genkit.NoStream) (*flowOutput, error) { + var docs []*ai.Document + for _, m := range input { + s := fmt.Sprintf("%s %g \n %s", m.Title, m.Price, m.Description) + metadata := map[string]any{ + "menuItem": m, + } + docs = append(docs, ai.DocumentFromText(s, metadata)) + } + req := &ai.IndexerRequest{ + Documents: docs, + } + if err := datastore.Index(ctx, req); err != nil { + return nil, err + } + + ret := &flowOutput{ + Rows: len(input), + } + return ret, nil + }, + ) + + genkit.DefineFlow("s04_ragMenuQuestion", + func(ctx context.Context, input *menuQuestionInput, _ genkit.NoStream) (*answerOutput, error) { + req := &ai.RetrieverRequest{ + Document: ai.DocumentFromText(input.Question, nil), + Options: &localvec.RetrieverOptions{ + K: 3, + }, + } + resp, err := datastore.Retrieve(ctx, req) + if err != nil { + return nil, err + } + + var menuItems []*menuItem + for _, doc := range resp.Documents { + menuItems = append(menuItems, doc.Metadata["menuItem"].(*menuItem)) + } + questionInput := &dataMenuQuestionInput{ + MenuData: menuItems, + Question: input.Question, + } + + preq := &ai.PromptRequest{ + Variables: questionInput, + } + presp, err := ragDataMenuPrompt.Generate(ctx, preq, nil) + if err != nil { + return nil, err + } + + ret := &answerOutput{ + Answer: presp.Candidates[0].Message.Content[0].Text, + } + return ret, nil + }, + ) + + return nil +} diff --git a/go/samples/menu/s05.go b/go/samples/menu/s05.go new file mode 100644 index 0000000000..285a9a5235 --- /dev/null +++ b/go/samples/menu/s05.go @@ -0,0 +1,141 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/base64" + "os" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/dotprompt" + "github.com/invopop/jsonschema" +) + +type imageURLInput struct { + ImageURL string `json:"imageUrl"` +} + +func setup05(ctx context.Context) error { + readMenuPrompt, err := dotprompt.Define("s05_readMenu", + ` + Extract _all_ of the text, in order, + from the following image of a restaurant menu. + + {{media url=imageUrl}}`, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro-vision", + InputSchema: jsonschema.Reflect(imageURLInput{}), + OutputFormat: ai.OutputFormatText, + GenerationConfig: &ai.GenerationCommonConfig{ + Temperature: 0.1, + }, + }, + ) + if err != nil { + return err + } + + textMenuPrompt, err := dotprompt.Define("s05_textMenu", + ` + You are acting as Walt, a helpful AI assistant here at the restaurant. + You can answer questions about the food on the menu or any other questions + customers have about food in general. + + Here is the text of today's menu to help you answer the customer's question: + {{menuText}} + + Answer this customer's question: + {{question}}? + `, + &dotprompt.Config{ + Model: "google-vertexai/gemini-1.0-pro", + InputSchema: textMenuQuestionInputSchema, + OutputFormat: ai.OutputFormatText, + GenerationConfig: &ai.GenerationCommonConfig{ + Temperature: 0.3, + }, + }, + ) + if err != nil { + return err + } + + // Define a flow that takes an image, passes it to Gemini Vision Pro, + // and extracts all of the text from the photo of the menu. + // Note that this example uses a hard-coded image file, as image input + // is not currently available in the Development UI runners. + readMenuFlow := genkit.DefineFlow("s05_readMenuFlow", + func(ctx context.Context, _ struct{}, _ genkit.NoStream) (string, error) { + image, err := os.ReadFile("testdata/menu.jpeg") + if err != nil { + return "", err + } + b64 := base64.StdEncoding.AppendEncode(nil, image) + imageDataURL := "data:image/jpeg;base64," + string(b64) + preq := &ai.PromptRequest{ + Variables: &imageURLInput{ + ImageURL: imageDataURL, + }, + } + presp, err := readMenuPrompt.Generate(ctx, preq, nil) + if err != nil { + return "", err + } + + ret := presp.Candidates[0].Message.Content[0].Text + return ret, nil + }, + ) + + // Define a flow that generates a response to the question. + // Just returns the LLM's text response to the question. + + textMenuQuestionFlow := genkit.DefineFlow("s05_textMenuQuestion", + func(ctx context.Context, input *textMenuQuestionInput, _ genkit.NoStream) (*answerOutput, error) { + preq := &ai.PromptRequest{ + Variables: input, + } + presp, err := textMenuPrompt.Generate(ctx, preq, nil) + if err != nil { + return nil, err + } + ret := &answerOutput{ + Answer: presp.Candidates[0].Message.Content[0].Text, + } + return ret, nil + }, + ) + + // Define a third composite flow that chains the first two flows. + + genkit.DefineFlow("s05_visionMenuQuestion", + func(ctx context.Context, input *menuQuestionInput, _ genkit.NoStream) (*answerOutput, error) { + menuText, err := genkit.RunFlow(ctx, readMenuFlow, struct{}{}) + if err != nil { + return nil, err + } + + questionInput := &textMenuQuestionInput{ + MenuText: menuText, + Question: input.Question, + } + return genkit.RunFlow(ctx, textMenuQuestionFlow, questionInput) + }, + ) + + return nil +} diff --git a/go/samples/menu/testdata/menu.jpeg b/go/samples/menu/testdata/menu.jpeg new file mode 100644 index 0000000000..ae096cd7bd Binary files /dev/null and b/go/samples/menu/testdata/menu.jpeg differ diff --git a/go/samples/menu/testdata/menu.json b/go/samples/menu/testdata/menu.json new file mode 100644 index 0000000000..d46739205d --- /dev/null +++ b/go/samples/menu/testdata/menu.json @@ -0,0 +1,97 @@ +[ + { + "title": "Mozzarella Sticks", + "price": 8, + "description": "Crispy fried mozzarella sticks served with marinara sauce." + }, + { + "title": "Chicken Wings", + "price": 10, + "description": "Crispy fried chicken wings tossed in your choice of sauce." + }, + { + "title": "Nachos", + "price": 12, + "description": "Crispy tortilla chips topped with melted cheese, chili, sour cream, and salsa." + }, + { + "title": "Onion Rings", + "price": 7, + "description": "Crispy fried onion rings served with ranch dressing." + }, + { + "title": "French Fries", + "price": 5, + "description": "Crispy fried french fries." + }, + { + "title": "Mashed Potatoes", + "price": 6, + "description": "Creamy mashed potatoes." + }, + { + "title": "Coleslaw", + "price": 4, + "description": "Homemade coleslaw." + }, + { + "title": "Classic Cheeseburger", + "price": 12, + "description": "A juicy beef patty topped with melted American cheese, lettuce, tomato, and onion on a toasted bun." + }, + { + "title": "Bacon Cheeseburger", + "price": 14, + "description": "A classic cheeseburger with the addition of crispy bacon." + }, + { + "title": "Mushroom Swiss Burger", + "price": 15, + "description": "A beef patty topped with sautéed mushrooms, melted Swiss cheese, and a creamy horseradish sauce." + }, + { + "title": "Chicken Sandwich", + "price": 13, + "description": "A crispy chicken breast on a toasted bun with lettuce, tomato, and your choice of sauce." + }, + { + "title": "Pulled Pork Sandwich", + "price": 14, + "description": "Slow-cooked pulled pork on a toasted bun with coleslaw and barbecue sauce." + }, + { + "title": "Reuben Sandwich", + "price": 15, + "description": "Thinly sliced corned beef, Swiss cheese, sauerkraut, and Thousand Island dressing on rye bread." + }, + { + "title": "House Salad", + "price": 8, + "description": "Mixed greens with your choice of dressing." + }, + { + "title": "Caesar Salad", + "price": 9, + "description": "Romaine lettuce with croutons, Parmesan cheese, and Caesar dressing." + }, + { + "title": "Greek Salad", + "price": 10, + "description": "Mixed greens with feta cheese, olives, tomatoes, cucumbers, and red onions." + }, + { + "title": "Chocolate Lava Cake", + "price": 8, + "description": "A warm, gooey chocolate cake with a molten chocolate center." + }, + { + "title": "Apple Pie", + "price": 7, + "description": "A classic apple pie with a flaky crust and warm apple filling." + }, + { + "title": "Cheesecake", + "price": 8, + "description": "A creamy cheesecake with a graham cracker crust." + } +]