ai cooking
0
fork

Configure Feed

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

Wineingredients (#311)

* we'll come back top this

* update wine prompt

* pass kroger ingredients

* stupid linter

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
2a8a2e1d aa5c9aa3

+182 -49
+71 -9
internal/ai/client.go
··· 23 23 ) 24 24 25 25 type Client struct { 26 - apiKey string 27 - schema map[string]any 28 - model string 26 + apiKey string 27 + schema map[string]any 28 + wineSchema map[string]any 29 + model string 29 30 } 30 31 31 32 // todo collapse closer to ··· 80 81 Recipes []Recipe `json:"recipes" jsonschema:"required"` 81 82 } 82 83 84 + type WineSelection struct { 85 + Wines []Ingredient `json:"wines"` 86 + Commentary string `json:"commentary"` 87 + } 88 + 83 89 // ignoring model for now. 84 90 func NewClient(apiKey, _ string) *Client { 85 91 //ignor model for now. ··· 87 93 DoNotReference: true, // no $defs and no $ref 88 94 ExpandedStruct: true, // put the root type inline (not a $ref) 89 95 } 90 - schema := r.Reflect(&ShoppingList{}) 91 - schemaJSON, _ := json.Marshal(schema) 96 + recipesSchema := r.Reflect(&ShoppingList{}) 97 + recipesSchemaJSON, _ := json.Marshal(recipesSchema) 98 + wineSchema := r.Reflect(&WineSelection{}) 99 + wineSchemaJSON, _ := json.Marshal(wineSchema) 92 100 var m map[string]any 93 - _ = json.Unmarshal(schemaJSON, &m) 101 + _ = json.Unmarshal(recipesSchemaJSON, &m) 102 + var wine map[string]any 103 + _ = json.Unmarshal(wineSchemaJSON, &wine) 94 104 return &Client{ 95 - apiKey: apiKey, 96 - schema: m, 97 - model: openai.ChatModelGPT5_2, 105 + apiKey: apiKey, 106 + schema: m, 107 + wineSchema: wine, 108 + model: openai.ChatModelGPT5_2, 98 109 } 99 110 } 100 111 ··· 211 222 return "", fmt.Errorf("empty response from model") 212 223 } 213 224 return answer, nil 225 + } 226 + 227 + func (c *Client) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*WineSelection, error) { 228 + conversationID = strings.TrimSpace(conversationID) 229 + recipeTitle = strings.TrimSpace(recipeTitle) 230 + if conversationID == "" { 231 + return nil, fmt.Errorf("conversation ID is required for wine picks") 232 + } 233 + if recipeTitle == "" { 234 + return nil, fmt.Errorf("recipe title is required for wine picks") 235 + } 236 + if len(wines) == 0 { 237 + return nil, fmt.Errorf("wines are required for wine picks") 238 + } 239 + var wineTSV strings.Builder 240 + err := kroger.ToTSV(wines, &wineTSV) 241 + if err != nil { 242 + slog.ErrorContext(ctx, "Failed to convert wines to TSV", "error", err) 243 + return nil, err 244 + } 245 + client := openai.NewClient(option.WithAPIKey(c.apiKey)) 246 + input := []responses.ResponseInputItemUnionParam{} 247 + input = append(input, user(fmt.Sprintf("Recipe title: %s", recipeTitle))) 248 + input = append(input, user(fmt.Sprintf("Candidate wines in TSV format:\n%s", wineTSV.String()))) 249 + params := responses.ResponseNewParams{ 250 + Model: c.model, 251 + Instructions: openai.String( 252 + "Act as a sommelier. Select 1 to 2 wines from the provided TSV that pair well with the recipe title. " + 253 + "Return JSON with commentary (string) and wines (array). " + 254 + "Size wine appropriately to people eating. Price according to the meal fanciness" + 255 + "For each wine include name and optionally quantity/price when available from TSV.", 256 + ), 257 + Input: responses.ResponseNewParamsInputUnion{ 258 + OfInputItemList: input, 259 + }, 260 + Store: openai.Bool(true), 261 + Conversation: responses.ResponseNewParamsConversationUnion{ 262 + OfString: openai.String(conversationID), 263 + }, 264 + Text: scheme(c.wineSchema), 265 + } 266 + resp, err := client.Responses.New(ctx, params) 267 + if err != nil { 268 + return nil, fmt.Errorf("failed to pick wine: %w", err) 269 + } 270 + 271 + var selection WineSelection 272 + if err := json.Unmarshal([]byte(resp.OutputText()), &selection); err != nil { 273 + return nil, fmt.Errorf("failed to parse wine selection: %w", err) 274 + } 275 + return &selection, nil 214 276 } 215 277 216 278 // is this dependency on krorger unncessary? just pass in a blob of toml or whatever? same with last recipes?
+8 -10
internal/recipes/generator.go
··· 28 28 GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) 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 + PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) 31 32 Ready(ctx context.Context) error 32 33 } 33 34 ··· 60 61 }, nil 61 62 } 62 63 63 - func (g *Generator) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (string, error) { 64 + func (g *Generator) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 64 65 var styles []string 65 66 for _, style := range recipe.WineStyles { 66 67 style = strings.TrimSpace(style) ··· 69 70 } 70 71 } 71 72 if len(styles) == 0 { 72 - return "No wine Styles for recipe", nil 73 + return &ai.WineSelection{Commentary: "no wines styles for recipe", Wines: []ai.Ingredient{}}, nil 73 74 } 74 75 dateStr := date.Format("2006-01-02") 75 76 wines, err := asParallel(styles, func(style string) ([]kroger.Ingredient, error) { ··· 96 97 return winesOfStyle, nil 97 98 }) 98 99 if err != nil { 99 - return "", err 100 + return nil, err 100 101 } 101 102 102 103 if len(wines) == 0 { 103 - return "no wines of those styles found", nil 104 + return &ai.WineSelection{Commentary: "no wines found", Wines: []ai.Ingredient{}}, nil 104 105 } 105 106 wines = uniqueByDescription(wines) 106 107 107 - var sb strings.Builder 108 - _ = lo.Must(fmt.Fprintf(&sb, "Pick a wine that would go well with %q. Here are %d wines in TSV format.\n", recipe.Title, len(wines))) 109 - err = kroger.ToTSV(wines, &sb) 108 + selection, err := g.aiClient.PickWine(ctx, conversationID, recipe.Title, wines) 110 109 if err != nil { 111 - slog.ErrorContext(ctx, "Failed to convert wines to TSV", "error", err) 112 - return "", err 110 + return nil, err 113 111 } 114 - return g.aiClient.AskQuestion(ctx, sb.String(), conversationID) 112 + return selection, nil 115 113 } 116 114 117 115 func (g *Generator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) {
+33 -8
internal/recipes/generator_test.go
··· 6 6 "careme/internal/kroger" 7 7 "careme/internal/locations" 8 8 "context" 9 - "strings" 10 9 "testing" 11 10 "time" 12 11 ) ··· 20 19 } 21 20 22 21 type captureWineQuestionAIClient struct { 23 - question string 24 - answer string 22 + question string 23 + answer string 24 + recipeTitle string 25 + selection *ai.WineSelection 25 26 } 26 27 27 28 func (c *captureWineQuestionAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { ··· 37 38 return c.answer, nil 38 39 } 39 40 41 + func (c *captureWineQuestionAIClient) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) { 42 + c.recipeTitle = recipeTitle 43 + if c.selection != nil { 44 + return c.selection, nil 45 + } 46 + return &ai.WineSelection{ 47 + Wines: []ai.Ingredient{}, 48 + Commentary: c.answer, 49 + }, nil 50 + } 51 + 40 52 func (c *captureWineQuestionAIClient) Ready(ctx context.Context) error { 41 53 return nil 42 54 } ··· 69 81 t.Fatalf("failed to seed wine ingredients cache: %v", err) 70 82 } 71 83 72 - aiStub := &captureWineQuestionAIClient{answer: "Great with your dish."} 84 + aiStub := &captureWineQuestionAIClient{ 85 + answer: "Great with your dish.", 86 + selection: &ai.WineSelection{ 87 + Wines: []ai.Ingredient{{Name: "Cached Pinot Noir", Quantity: "750mL"}}, 88 + Commentary: "Great with your dish.", 89 + }, 90 + } 73 91 g := &Generator{ 74 92 io: IO(cacheStore), 75 93 aiClient: aiStub, ··· 83 101 if err != nil { 84 102 t.Fatalf("PickAWine returned error: %v", err) 85 103 } 86 - if got != aiStub.answer { 87 - t.Fatalf("unexpected answer: got %q want %q", got, aiStub.answer) 104 + if got == nil { 105 + t.Fatal("expected non-nil wine selection") 106 + return 88 107 } 89 - if !strings.Contains(aiStub.question, "Cached Pinot Noir") { 90 - t.Fatalf("expected cached wine to appear in question payload, got: %s", aiStub.question) 108 + if got.Commentary != aiStub.answer { 109 + t.Fatalf("unexpected commentary: got %q want %q", got.Commentary, aiStub.answer) 110 + } 111 + if got.Wines == nil || len(got.Wines) != 1 || got.Wines[0].Name != "Cached Pinot Noir" { 112 + t.Fatalf("unexpected wine selection payload: %+v", got.Wines) 113 + } 114 + if aiStub.recipeTitle != "Roast Chicken" { 115 + t.Fatalf("expected recipe title %q, got %q", "Roast Chicken", aiStub.recipeTitle) 91 116 } 92 117 }
+6 -3
internal/recipes/io_test.go
··· 159 159 t.Fatalf("failed to seed recipe entry: %v", err) 160 160 } 161 161 162 - if err := rio.SaveWine(t.Context(), hash, "Try a tempranillo."); err != nil { 163 - t.Fatalf("SaveWine failed: %v", err) 162 + if err := rio.SaveWine(t.Context(), hash, &ai.WineSelection{ 163 + Commentary: "Try a tempranillo.", 164 + Wines: []ai.Ingredient{{Name: "Tempranillo", Quantity: "750mL", Price: "$12.99"}}, 165 + }); err != nil { 166 + t.Fatal("shouldn't matter if theres a conflicting recipe key", "error", err) 164 167 } 165 168 166 169 if _, err := os.Stat(filepath.Join(tmpDir, wineRecommendationsCachePrefix, hash)); err != nil { ··· 171 174 if err != nil { 172 175 t.Fatalf("WineFromCache failed: %v", err) 173 176 } 174 - if got != "Try a tempranillo." { 177 + if got.Commentary != "Try a tempranillo." { 175 178 t.Fatalf("unexpected cached wine recommendation: got %q", got) 176 179 } 177 180 }
+6 -2
internal/recipes/mock.go
··· 397 397 return fmt.Sprintf("Mock answer: %s", question), nil 398 398 } 399 399 400 - func (m mock) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (string, error) { 400 + func (m mock) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 401 401 _ = ctx 402 402 _ = conversationID 403 + _ = location 403 404 _ = date 404 - return fmt.Sprintf("Mock wine pick for %s: try a medium-bodied red.", recipe.Title), nil 405 + return &ai.WineSelection{ 406 + Wines: []ai.Ingredient{}, 407 + Commentary: fmt.Sprintf("Mock wine pick for %s: try a medium-bodied red.", recipe.Title), 408 + }, nil 405 409 }
+14 -6
internal/recipes/server.go
··· 36 36 type generator interface { 37 37 GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) 38 38 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 39 - PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (string, error) 39 + PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) 40 40 Ready(ctx context.Context) error 41 41 } 42 42 ··· 230 230 http.Error(w, "missing recipe hash", http.StatusBadRequest) 231 231 return 232 232 } 233 - if recommendation, err := s.WineFromCache(ctx, hash); err == nil { 234 - FormatRecipeWineHTML(hash, recommendation, w) 233 + if selection, err := s.WineFromCache(ctx, hash); err == nil { 234 + if selection == nil { 235 + http.Error(w, "failed to load wine recommendation", http.StatusInternalServerError) 236 + return 237 + } 238 + FormatRecipeWineHTML(hash, selection.Commentary, w) 235 239 return 236 240 } else if !errors.Is(err, cache.ErrNotFound) { 237 241 slog.ErrorContext(ctx, "failed to load cached wine recommendation", "hash", hash, "error", err) ··· 263 267 264 268 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second) 265 269 defer cancel() 266 - recommendation, err := s.generator.PickAWine(ctx, conversationID, p.Location.ID, *recipe, p.Date) 270 + selection, err := s.generator.PickAWine(ctx, conversationID, p.Location.ID, *recipe, p.Date) 267 271 if err != nil { 268 272 slog.ErrorContext(ctx, "failed to pick wine", "hash", hash, "conversation_id", conversationID, "error", err) 269 273 http.Error(w, "failed to pick wine", http.StatusInternalServerError) 270 274 return 271 275 } 272 - if err := s.SaveWine(ctx, hash, recommendation); err != nil { 276 + if selection == nil { 277 + http.Error(w, "failed to pick wine", http.StatusInternalServerError) 278 + return 279 + } 280 + if err := s.SaveWine(ctx, hash, selection); err != nil { 273 281 slog.ErrorContext(ctx, "failed to save wine recommendation", "hash", hash, "error", err) 274 282 } 275 283 276 - FormatRecipeWineHTML(hash, recommendation, w) 284 + FormatRecipeWineHTML(hash, selection.Commentary, w) 277 285 } 278 286 279 287 func (s *server) handleFeedback(w http.ResponseWriter, r *http.Request) {
+4 -3
internal/recipes/server_test.go
··· 307 307 return "Try chicken thighs at the same cook time.", nil 308 308 } 309 309 310 - func (c *captureQuestionGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (string, error) { 310 + func (c *captureQuestionGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 311 311 if c.panicOnWine { 312 312 panic("unexpected call to PickAWine") 313 313 } 314 + _ = location 314 315 c.winePickCalls++ 315 316 c.lastWinePick.conversationID = conversationID 316 317 c.lastWinePick.recipeTitle = recipe.Title 317 318 c.lastWinePick.date = date 318 319 if c.wineRecommendation != "" { 319 - return c.wineRecommendation, nil 320 + return &ai.WineSelection{Commentary: c.wineRecommendation, Wines: []ai.Ingredient{}}, nil 320 321 } 321 - return "Try a chilled sauvignon blanc.", nil 322 + return &ai.WineSelection{Commentary: "Try a chilled sauvignon blanc.", Wines: []ai.Ingredient{}}, nil 322 323 } 323 324 324 325 func (c *captureQuestionGenerator) Ready(ctx context.Context) error {
+40 -8
internal/recipes/wine.go
··· 1 1 package recipes 2 2 3 3 import ( 4 + "careme/internal/ai" 4 5 "careme/internal/cache" 5 6 "context" 7 + "encoding/json" 8 + "fmt" 6 9 "io" 7 10 "log/slog" 11 + "strings" 8 12 ) 9 13 10 14 const wineRecommendationsCachePrefix = "wine_recommendations/" ··· 13 17 return wineRecommendationsCachePrefix + hash 14 18 } 15 19 16 - func (rio recipeio) WineFromCache(ctx context.Context, hash string) (string, error) { 17 - return rio.readStringFromCache(ctx, recipeWineCacheKey(hash)) 20 + func (rio recipeio) WineFromCache(ctx context.Context, hash string) (*ai.WineSelection, error) { 21 + body, err := rio.readBytesFromCache(ctx, recipeWineCacheKey(hash)) 22 + if err != nil { 23 + return nil, err 24 + } 25 + 26 + var selection ai.WineSelection 27 + if err := json.Unmarshal(body, &selection); err == nil { 28 + if selection.Wines == nil { 29 + selection.Wines = []ai.Ingredient{} 30 + } 31 + return &selection, nil 32 + } 33 + 34 + // Legacy compatibility: cache may contain plain commentary text. 35 + commentary := strings.TrimSpace(string(body)) 36 + if commentary == "" { 37 + return nil, fmt.Errorf("wine cache entry is empty") 38 + } 39 + return &ai.WineSelection{ 40 + Wines: []ai.Ingredient{}, 41 + Commentary: commentary, 42 + }, nil 18 43 } 19 44 20 - func (rio recipeio) SaveWine(ctx context.Context, hash string, recommendation string) error { 21 - return rio.Cache.Put(ctx, recipeWineCacheKey(hash), recommendation, cache.Unconditional()) 45 + func (rio recipeio) SaveWine(ctx context.Context, hash string, selection *ai.WineSelection) error { 46 + if selection == nil { 47 + return fmt.Errorf("wine selection is required") 48 + } 49 + body, err := json.Marshal(selection) 50 + if err != nil { 51 + return err 52 + } 53 + return rio.Cache.Put(ctx, recipeWineCacheKey(hash), string(body), cache.Unconditional()) 22 54 } 23 55 24 - func (rio recipeio) readStringFromCache(ctx context.Context, key string) (string, error) { 56 + func (rio recipeio) readBytesFromCache(ctx context.Context, key string) ([]byte, error) { 25 57 reader, err := rio.Cache.Get(ctx, key) 26 58 if err != nil { 27 - return "", err 59 + return nil, err 28 60 } 29 61 defer func() { 30 62 if err := reader.Close(); err != nil { ··· 34 66 35 67 body, err := io.ReadAll(reader) 36 68 if err != nil { 37 - return "", err 69 + return nil, err 38 70 } 39 - return string(body), nil 71 + return body, nil 40 72 }