ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Wine suggestion no convo/gptmini (#421)

* wines independent of conversation

* fix up prompt

* fumpt

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
67aaf304 da2a232d

+114 -97
+39 -30
internal/ai/client.go
··· 28 28 schema map[string]any 29 29 wineSchema map[string]any 30 30 model string 31 + wineModel string 31 32 } 32 33 33 34 type GeneratedImage struct { ··· 110 111 schema: m, 111 112 wineSchema: wine, 112 113 model: openai.ChatModelGPT5_4, 114 + wineModel: openai.ChatModelGPT5Mini, 113 115 } 114 116 } 115 117 ··· 163 165 - If the recipe has multiple components, show them plated together 164 166 ` 165 167 168 + const winePrompt = ` 169 + Act as a sommelier for the recipe provided below 170 + Select 1 to 2 wines from the provided TSV that best match the dish 171 + Return JSON with wines (ingredient array) and concise commentary explaining why those specific bottles work. 172 + Only choose wines present in the TSV. For each wine include name and optionally quantity/price when available from TSV. 173 + Be creative not always the same safe picks. Consider the specific ingredients, cooking method, and flavor profile of the dish when making your selection. 174 + Also for fancier/more expensive dishes consider more expensive wines. 175 + ` 176 + 166 177 const ( 167 178 recipeImageModel = openai.ImageModelGPTImage1_5 // dalle-3 is getting deprecated. 1.5 seems way better than 1. 168 179 // WebP is materially smaller for these recipe photos on mobile, and GPT image models support direct WebP output. ··· 289 300 }, nil 290 301 } 291 302 292 - func (c *Client) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*WineSelection, error) { 293 - conversationID = strings.TrimSpace(conversationID) 294 - recipeTitle = strings.TrimSpace(recipeTitle) 295 - if conversationID == "" { 296 - return nil, fmt.Errorf("conversation ID is required for wine picks") 297 - } 298 - if recipeTitle == "" { 299 - return nil, fmt.Errorf("recipe title is required for wine picks") 300 - } 301 - if len(wines) == 0 { 302 - return nil, fmt.Errorf("wines are required for wine picks") 303 - } 304 - var wineTSV strings.Builder 305 - err := kroger.ToTSV(wines, &wineTSV) 303 + func (c *Client) PickWine(ctx context.Context, recipe Recipe, wines []kroger.Ingredient) (*WineSelection, error) { 304 + prompt, err := buildWineSelectionPrompt(recipe, wines) 306 305 if err != nil { 307 - slog.ErrorContext(ctx, "Failed to convert wines to TSV", "error", err) 308 - return nil, err 306 + return nil, fmt.Errorf("failed to build wine selection prompt: %w", err) 309 307 } 310 308 client := openai.NewClient(option.WithAPIKey(c.apiKey)) 311 - input := []responses.ResponseInputItemUnionParam{user(fmt.Sprintf("Candidate wines:\n%s", wineTSV.String()))} 312 309 params := responses.ResponseNewParams{ 313 - Model: c.model, 314 - Instructions: openai.String( 315 - "Act as a sommelier. Select 1 to 2 wines from the provided TSV that pair well with the recipe " + recipeTitle + "." + 316 - "Return JSON with wines (ingredient array) and commentary about why those particular wines work well" + 317 - "Pick wine sizes appropriate to number of people. Price according to the meal fanciness" + 318 - "For each wine include name and optionally quantity/price when available from TSV.", 319 - ), 310 + Model: c.wineModel, 311 + Instructions: openai.String(winePrompt), 320 312 Input: responses.ResponseNewParamsInputUnion{ 321 - OfInputItemList: input, 322 - }, 323 - Store: openai.Bool(true), 324 - Conversation: responses.ResponseNewParamsConversationUnion{ 325 - OfString: openai.String(conversationID), 313 + OfInputItemList: []responses.ResponseInputItemUnionParam{user(prompt)}, 326 314 }, 327 315 Text: scheme(c.wineSchema), 328 316 } ··· 388 376 fmt.Fprintf(&promptBuilder, "%s\n", recipe.Description) 389 377 fmt.Fprintf(&promptBuilder, "Instructions:\n") 390 378 for _, ins := range recipe.Instructions { 391 - fmt.Fprintf(&promptBuilder, "%s\n", ins) 379 + fmt.Fprintf(&promptBuilder, "- %s\n", ins) 380 + } 381 + return promptBuilder.String(), nil 382 + } 383 + 384 + // similiar to image generation builder 385 + func buildWineSelectionPrompt(recipe Recipe, wines []kroger.Ingredient) (string, error) { 386 + var wineTSV strings.Builder 387 + if err := kroger.ToTSV(wines, &wineTSV); err != nil { 388 + return "", fmt.Errorf("failed to convert wines to TSV: %w", err) 389 + } 390 + 391 + var promptBuilder strings.Builder 392 + fmt.Fprintf(&promptBuilder, "Recipe:\n") 393 + fmt.Fprintf(&promptBuilder, "%s\n", recipe.Title) 394 + fmt.Fprintf(&promptBuilder, "%s\n", recipe.Description) 395 + fmt.Fprintf(&promptBuilder, "Instructions:\n") 396 + for _, ins := range recipe.Instructions { 397 + fmt.Fprintf(&promptBuilder, "- %s\n", ins) 392 398 } 399 + fmt.Fprintf(&promptBuilder, "Existing drink pairing note: %s\n", recipe.DrinkPairing) 400 + // add cost estimate when we believ it? 401 + fmt.Fprintf(&promptBuilder, "\nCandidate wines TSV:\n%s", wineTSV.String()) 393 402 return promptBuilder.String(), nil 394 403 } 395 404
+49 -1
internal/ai/recipe_test.go
··· 4 4 "slices" 5 5 "strings" 6 6 "testing" 7 + 8 + "careme/internal/kroger" 7 9 ) 8 10 9 11 func TestRecipeComputeHash(t *testing.T) { ··· 109 111 if !strings.Contains(prompt, "realistic overhead food photograph") { 110 112 t.Fatalf("expected image prompt instructions in prompt: %s", prompt) 111 113 } 112 - if !strings.Contains(prompt, "Recipe:\nRoast Chicken\nCrisp skin and herbs.\nInstructions:\nRoast until golden.\n") { 114 + if !strings.Contains(prompt, "Recipe:\nRoast Chicken\nCrisp skin and herbs.\nInstructions:\n- Roast until golden.\n") { 113 115 t.Fatalf("expected recipe summary in prompt: %s", prompt) 114 116 } 115 117 } 118 + 119 + func TestBuildWineSelectionPrompt(t *testing.T) { 120 + recipe := Recipe{ 121 + Title: "Roast Chicken", 122 + Description: "Crisp skin and herbs.", 123 + CookTime: "45 minutes", 124 + CostEstimate: "$18-24", 125 + Ingredients: []Ingredient{ 126 + {Name: "Chicken", Quantity: "1 whole", Price: "$12"}, 127 + {Name: "Lemon", Quantity: "1", Price: "$1"}, 128 + }, 129 + Instructions: []string{"Roast until golden.", "Finish with lemon juice."}, 130 + Health: "Balanced dinner", 131 + DrinkPairing: "Pinot Noir", 132 + WineStyles: []string{"Pinot Noir", "Chardonnay"}, 133 + } 134 + wines := []kroger.Ingredient{ 135 + {Description: strPtr("Pinot Noir"), Size: strPtr("750mL"), PriceRegular: float32Ptr(13.99)}, 136 + } 137 + 138 + prompt, err := buildWineSelectionPrompt(recipe, wines) 139 + if err != nil { 140 + t.Fatalf("buildWineSelectionPrompt returned error: %v", err) 141 + } 142 + expect := "Chicken\nCrisp skin and herbs." 143 + if !strings.Contains(prompt, expect) { 144 + t.Fatalf("expected recipe summary in prompt: %s\n\n got \n %s", expect, prompt) 145 + } 146 + if !strings.Contains(prompt, "Existing drink pairing note: Pinot Noir") { 147 + t.Fatalf("expected pairing hints in prompt: %s", prompt) 148 + } 149 + if !strings.Contains(prompt, "- Roast until golden.\n- Finish with lemon juice.\n") { 150 + t.Fatalf("expected instructions replay in prompt: %s", prompt) 151 + } 152 + if !strings.Contains(prompt, "Candidate wines TSV:\nProductId\tAisleNumber\tBrand\tDescription\tSize\tPriceRegular\tPriceSale\n\t\t\tPinot Noir\t750mL\t13.99\t13.99\n") { 153 + t.Fatalf("expected candidate wines TSV in prompt: %s", prompt) 154 + } 155 + } 156 + 157 + func strPtr(s string) *string { 158 + return &s 159 + } 160 + 161 + func float32Ptr(v float32) *float32 { 162 + return &v 163 + }
+3 -3
internal/recipes/generator.go
··· 29 29 Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) 30 30 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 31 31 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 32 - PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) 32 + PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) 33 33 Ready(ctx context.Context) error 34 34 } 35 35 ··· 63 63 }, nil 64 64 } 65 65 66 - func (g *Generator) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 66 + func (g *Generator) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 67 67 var styles []string 68 68 for _, style := range recipe.WineStyles { 69 69 style = strings.TrimSpace(style) ··· 115 115 } 116 116 wines = uniqueByDescription(wines) 117 117 118 - selection, err := g.aiClient.PickWine(ctx, conversationID, recipe.Title, wines) 118 + selection, err := g.aiClient.PickWine(ctx, recipe, wines) 119 119 if err != nil { 120 120 return nil, err 121 121 }
+15 -16
internal/recipes/generator_test.go
··· 14 14 ) 15 15 16 16 type captureWineQuestionAIClient struct { 17 - question string 18 - answer string 19 - recipeTitle string 20 - selection *ai.WineSelection 17 + question string 18 + answer string 19 + recipe ai.Recipe 20 + selection *ai.WineSelection 21 21 } 22 22 23 23 type captureRegenerateAIClient struct { ··· 49 49 panic("unexpected call to GenerateRecipeImage") 50 50 } 51 51 52 - func (c *captureWineQuestionAIClient) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) { 53 - c.recipeTitle = recipeTitle 52 + func (c *captureWineQuestionAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 53 + c.recipe = recipe 54 54 if c.selection != nil { 55 55 return c.selection, nil 56 56 } ··· 85 85 panic("unexpected call to GenerateRecipeImage") 86 86 } 87 87 88 - func (c *captureRegenerateAIClient) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) { 88 + func (c *captureRegenerateAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 89 89 panic("unexpected call to PickWine") 90 90 } 91 91 ··· 120 120 121 121 func TestPickAWine_UsesCachedIngredientsForStyleDateAndLocation(t *testing.T) { 122 122 const ( 123 - location = "70500874" 124 - conversation = "conv-1" 125 - style = "Pinot Noir" 123 + location = "70500874" 124 + style = "Pinot Noir" 126 125 ) 127 126 cacheDate := time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC) 128 127 ··· 150 149 aiClient: aiStub, 151 150 } 152 151 153 - got, err := g.PickAWine(t.Context(), conversation, location, ai.Recipe{ 152 + got, err := g.PickAWine(t.Context(), location, ai.Recipe{ 154 153 Title: "Roast Chicken", 155 154 WineStyles: []string{style}, 156 155 }, cacheDate) ··· 167 166 if got.Wines == nil || len(got.Wines) != 1 || got.Wines[0].Name != "Cached Pinot Noir" { 168 167 t.Fatalf("unexpected wine selection payload: %+v", got.Wines) 169 168 } 170 - if aiStub.recipeTitle != "Roast Chicken" { 171 - t.Fatalf("expected recipe title %q, got %q", "Roast Chicken", aiStub.recipeTitle) 169 + if aiStub.recipe.Title != "Roast Chicken" { 170 + t.Fatalf("expected recipe title %q, got %q", "Roast Chicken", aiStub.recipe.Title) 172 171 } 173 172 } 174 173 ··· 197 196 staplesProvider: staplesStub, 198 197 } 199 198 200 - got, err := g.PickAWine(t.Context(), "conv-wholefoods", "wholefoods_10216", ai.Recipe{ 199 + got, err := g.PickAWine(t.Context(), "wholefoods_10216", ai.Recipe{ 201 200 Title: "Salmon", 202 201 WineStyles: []string{"Pinot Noir"}, 203 202 }, time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)) ··· 214 213 if got == nil || len(got.Wines) != 3 { 215 214 t.Fatalf("unexpected wine selection: %+v", got) 216 215 } 217 - if aiStub.recipeTitle != "Salmon" { 218 - t.Fatalf("expected recipe title %q, got %q", "Salmon", aiStub.recipeTitle) 216 + if aiStub.recipe.Title != "Salmon" { 217 + t.Fatalf("expected recipe title %q, got %q", "Salmon", aiStub.recipe.Title) 219 218 } 220 219 } 221 220
+1 -2
internal/recipes/mock.go
··· 419 419 }, nil 420 420 } 421 421 422 - func (m mock) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 422 + func (m mock) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 423 423 _ = ctx 424 - _ = conversationID 425 424 _ = location 426 425 _ = date 427 426 return &ai.WineSelection{
+3 -33
internal/recipes/server.go
··· 76 76 GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) 77 77 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 78 78 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 79 - PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) 79 + PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) 80 80 Ready(ctx context.Context) error 81 81 } 82 82 ··· 437 437 return 438 438 } 439 439 440 - conversationID := strings.TrimSpace(loadConversationIDForRecipe(ctx, s.recipeio, recipe.OriginHash)) 441 - if conversationID == "" { 442 - http.Error(w, "conversation id not found", http.StatusUnprocessableEntity) 443 - return 444 - } 445 - 446 440 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second) 447 441 defer cancel() 448 - selection, err := s.generator.PickAWine(ctx, conversationID, p.Location.ID, *recipe, p.Date) 442 + selection, err := s.generator.PickAWine(ctx, p.Location.ID, *recipe, p.Date) 449 443 if err != nil { 450 - slog.ErrorContext(ctx, "failed to pick wine", "hash", hash, "conversation_id", conversationID, "error", err) 444 + slog.ErrorContext(ctx, "failed to pick wine", "hash", hash, "error", err) 451 445 http.Error(w, "failed to pick wine", http.StatusInternalServerError) 452 446 return 453 447 } ··· 1073 1067 1074 1068 func isHTMXRequest(r *http.Request) bool { 1075 1069 return strings.EqualFold(r.Header.Get("HX-Request"), "true") 1076 - } 1077 - 1078 - func loadConversationIDForRecipe(ctx context.Context, rio recipeio, originHash string) string { 1079 - originHash = strings.TrimSpace(originHash) 1080 - if originHash == "" { 1081 - return "" 1082 - } 1083 - if normalizedHash, ok := legacyHashToCurrent(originHash, legacyRecipeHashSeed); ok { 1084 - originHash = normalizedHash 1085 - } 1086 - if p, err := rio.ParamsFromCache(ctx, originHash); err == nil { 1087 - if conversationID := strings.TrimSpace(p.ConversationID); conversationID != "" { 1088 - return conversationID 1089 - } 1090 - } else if !errors.Is(err, cache.ErrNotFound) { 1091 - slog.ErrorContext(ctx, "failed to load recipe params for conversation", "hash", originHash, "error", err) 1092 - } 1093 - 1094 - if slist, err := rio.FromCache(ctx, originHash); err == nil { 1095 - return strings.TrimSpace(slist.ConversationID) 1096 - } else if !errors.Is(err, cache.ErrNotFound) { 1097 - slog.ErrorContext(ctx, "failed to load shopping list for conversation", "hash", originHash, "error", err) 1098 - } 1099 - return "" 1100 1070 } 1101 1071 1102 1072 func parseFeedbackBool(value string) (bool, error) {
+4 -12
internal/recipes/server_test.go
··· 577 577 panic("unexpected call to GenerateRecipeImage") 578 578 } 579 579 580 - func (c *captureKickgenerationGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 580 + func (c *captureKickgenerationGenerator) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 581 581 panic("unexpected call to PickAWine") 582 582 } 583 583 ··· 652 652 type captureQuestionGenerator struct { 653 653 lastQuestion string 654 654 lastWinePick struct { 655 - conversationID string 656 - recipeTitle string 657 - date time.Time 655 + recipeTitle string 656 + date time.Time 658 657 } 659 658 wineRecommendation string 660 659 winePickCalls int ··· 689 688 }, nil 690 689 } 691 690 692 - func (c *captureQuestionGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 691 + func (c *captureQuestionGenerator) PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 693 692 if c.panicOnWine { 694 693 panic("unexpected call to PickAWine") 695 694 } 696 695 _ = location 697 696 c.winePickCalls++ 698 - c.lastWinePick.conversationID = conversationID 699 697 c.lastWinePick.recipeTitle = recipe.Title 700 698 c.lastWinePick.date = date 701 699 if c.wineRecommendation != "" { ··· 841 839 ) 842 840 843 841 p := DefaultParams(&locations.Location{ID: "70003002", Name: "Wine Test Store"}, time.Now()) 844 - p.ConversationID = "conv-wine" 845 842 originHash := p.Hash() 846 843 if err := s.SaveParams(t.Context(), p); err != nil { 847 844 t.Fatalf("failed to save params: %v", err) ··· 876 873 if !strings.Contains(body, "Try a chilled sauvignon blanc.") { 877 874 t.Fatalf("expected wine recommendation in response, got body: %s", body) 878 875 } 879 - if got, want := g.lastWinePick.conversationID, "conv-wine"; got != want { 880 - t.Fatalf("expected conversation id %q, got %q", want, got) 881 - } 882 876 if got, want := g.lastWinePick.recipeTitle, "Roast Chicken"; got != want { 883 877 t.Fatalf("expected recipe title %q, got %q", want, got) 884 878 } ··· 899 893 ) 900 894 901 895 p := DefaultParams(&locations.Location{ID: "70003002", Name: "Wine Test Store"}, time.Now()) 902 - p.ConversationID = "conv-wine" 903 896 originHash := p.Hash() 904 897 if err := s.SaveParams(t.Context(), p); err != nil { 905 898 t.Fatalf("failed to save params: %v", err) ··· 951 944 ) 952 945 953 946 p := DefaultParams(&locations.Location{ID: "70003002", Name: "Wine Test Store"}, time.Now()) 954 - p.ConversationID = "conv-wine" 955 947 originHash := p.Hash() 956 948 if err := s1.SaveParams(t.Context(), p); err != nil { 957 949 t.Fatalf("failed to save params: %v", err)