ai cooking
0
fork

Configure Feed

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

at master 538 lines 20 kB view raw
1package ai 2 3import ( 4 "context" 5 "encoding/base64" 6 "encoding/json" 7 "fmt" 8 "hash/fnv" 9 "io" 10 "log/slog" 11 "net/http" 12 "strings" 13 "time" 14 15 locationtypes "careme/internal/locations/types" 16 17 openai "github.com/openai/openai-go/v3" 18 "github.com/openai/openai-go/v3/option" 19 "github.com/openai/openai-go/v3/responses" 20 "github.com/samber/lo" 21 22 "github.com/invopop/jsonschema" 23) 24 25type GeneratedImage struct { 26 Body io.Reader 27} 28 29const ( 30 defaultRecipeModel = "gpt-5.5" 31 defaultWineModel = openai.ChatModelGPT5Mini 32) 33 34// how close should this be to Input ingredint. Should we also add aisle or just echo productid so we can look it up 35type Ingredient struct { 36 Name string `json:"name"` 37 Quantity string `json:"quantity"` // should this and price be numbers? need units then 38 Price string `json:"price"` // TODO exclude empty 39} 40 41type Recipe struct { 42 Title string `json:"title"` 43 Description string `json:"description"` 44 CookTime string `json:"cook_time"` 45 CostEstimate string `json:"cost_estimate"` 46 Ingredients []Ingredient `json:"ingredients"` 47 Instructions []string `json:"instructions"` 48 Health string `json:"health"` 49 DrinkPairing string `json:"drink_pairing"` 50 WineStyles []string `json:"wine_styles"` 51 ResponseID string `json:"response_id,omitempty" jsonschema:"-"` // not in schema 52 OriginHash string `json:"origin_hash,omitempty" jsonschema:"-"` // not in schema 53 ParentHash string `json:"parent_hash,omitempty" jsonschema:"-"` // regeneration metadata, not in schema 54 Saved bool `json:"previously_saved,omitempty" jsonschema:"-"` // not in schema 55} 56 57// ComputeHash calculates the fnv128 hash of the recipe content 58func (r *Recipe) ComputeHash() string { 59 // OriginHash, ParentHash, Saved are intentionally excluded because they describe provenance or UI state, 60 // not the recipe content itself. If ancestor links ever need to affect identity, that 61 // is a separate model change and should not happen implicitly here. 62 fnv := fnv.New128a() 63 lo.Must(io.WriteString(fnv, r.Title)) 64 lo.Must(io.WriteString(fnv, r.Description)) 65 lo.Must(io.WriteString(fnv, r.CookTime)) 66 lo.Must(io.WriteString(fnv, r.CostEstimate)) 67 for _, ing := range r.Ingredients { 68 lo.Must(io.WriteString(fnv, ing.Name)) 69 lo.Must(io.WriteString(fnv, ing.Quantity)) 70 lo.Must(io.WriteString(fnv, ing.Price)) 71 } 72 for _, instr := range r.Instructions { 73 lo.Must(io.WriteString(fnv, instr)) 74 } 75 lo.Must(io.WriteString(fnv, r.Health)) 76 lo.Must(io.WriteString(fnv, r.DrinkPairing)) 77 return base64.URLEncoding.EncodeToString(fnv.Sum(nil)) 78} 79 80// intentionally not including ResponseID to preserve old hashes 81// we used to use conversation id here but then you can end up sharing conversations with strangers which is kind of wierd. 82// now we can reuse first recipes and people can go off in different directions. 83type ShoppingList struct { 84 ResponseID string `json:"response_id,omitempty" jsonschema:"-"` 85 Recipes []Recipe `json:"recipes" jsonschema:"required"` 86 Discarded []Recipe `json:"-" jsonschema:"-"` 87} 88 89// question threads go off from the response that generated the recipe. 90type QuestionResponse struct { 91 Answer string 92 ResponseID string 93} 94 95type WineSelection struct { 96 Wines []Ingredient `json:"wines"` 97 Commentary string `json:"commentary"` 98} 99 100type client struct { 101 schema map[string]any 102 wineSchema map[string]any 103 model string 104 wineModel string 105 oai openai.Client 106} 107 108// ignoring model for now. 109func NewClient(apiKey, _ string, httpClient *http.Client) *client { 110 // ignor model for now. 111 r := jsonschema.Reflector{ 112 DoNotReference: true, // no $defs and no $ref 113 ExpandedStruct: true, // put the root type inline (not a $ref) 114 } 115 recipesSchema := r.Reflect(&ShoppingList{}) 116 recipesSchemaJSON, _ := json.Marshal(recipesSchema) 117 wineSchema := r.Reflect(&WineSelection{}) 118 wineSchemaJSON, _ := json.Marshal(wineSchema) 119 var m map[string]any 120 _ = json.Unmarshal(recipesSchemaJSON, &m) 121 var wine map[string]any 122 _ = json.Unmarshal(wineSchemaJSON, &wine) 123 opts := []option.RequestOption{option.WithAPIKey(apiKey)} 124 if httpClient != nil { 125 opts = append(opts, option.WithHTTPClient(httpClient)) 126 } 127 aiClient := openai.NewClient(opts...) 128 return &client{ 129 oai: aiClient, 130 schema: m, 131 wineSchema: wine, 132 model: defaultRecipeModel, 133 wineModel: defaultWineModel, 134 } 135} 136 137const systemMessage = ` 138You are a professional chef and recipe developer helping working families cook varied weeknight dinners. 139 140# Outcome 141Create distinct, practical recipes using the provided sale ingredients, seasonal context, user preferences, and recent-recipe history. 142 143# Recipe Requirements 144- Default to 3 recipes for 2 people, under 1 hour, unless the user asks otherwise. 145- User instructions override defaults unless they make a recipe unsafe, uncookable, or impossible with the available ingredients. 146- Each recipe must include a protein plus at least one vegetable or starch component. 147- Use varied cuisines, cooking methods, textures, colors, and plating styles across the set. 148- Include pastas, noodles, stir-fries, stews, braises, curries, casseroles, or other compositions when they fit the ingredients. 149- Prioritize sale ingredients by value and quality. Only use prices from the input; never invent prices. 150- Pantry items are allowed when common and inexpensive. 151- Aim for healthy unless otherwise stated. Calorie estimates must be reasonable for the stated quantities and servings. 152- Include one richer or more special recipe when it fits the budget and ingredients, and mention that in the description. 153- Include wine pairing guidance when useful; otherwise explain briefly why a pairing is not needed. 154 155# Field Guidance 156- title: use a short, appetizing name. 157- description: make the dish sound appealing and note what makes it practical, special, or seasonal. 158- cook_time: provide a realistic estimate such as "35 minutes". 159- cost_estimate: align the range with listed priced ingredients. 160- ingredients: include quantities; include prices only when present in the input; common pantry items are allowed. 161- instructions: start with prep and end with plating; repeat amounts and prep details; do not include prices; do not prefix steps with numbers. 162- health: include plausible calories and macro notes for the stated servings. 163- drink_pairing: give concise sommelier guidance tied to the dish. 164- wine_styles: at most two searchable consumer wine styles, such as "Pinot Noir" or "Sauvignon Blanc"; no regions, parenthetical notes, commas, "or", or "*-style blend" phrasing. 165 166# Quality Checks 167Before responding, ensure recipes are cookable, realistic, non-contradictory, varied, correctly priced, safe, and visually appealing after plating. Do not include these checks in the output.` 168 169const recipeImagePromptInstructions = ` 170Generate a realistic overhead food photograph of a single finished plate. 171- Home cooked by a above average cook, not a restaurant or food stylist. 172- Keep plating simple and believable. No tweezers, foam, edible flowers, microgreens, or luxury flourishes unless in recipe instructions. 173- Use a simple kitchen counter, stovetop, sheet pan, wooden table, or casual dining table backdrop. 174- Use natural colors, ordinary cookware or tableware, and realistic portions 175- Avoid text, labels, branded packaging, people, hands, collages, and extra side dishes 176- If the recipe has multiple components, show them plated together 177` 178 179const winePrompt = ` 180Act as a sommelier for the recipe provided below 181Select 1 to 2 wines from the provided TSV that best match the dish 182Return JSON with wines (ingredient array) and concise commentary explaining why those specific bottles work. 183Only choose wines present in the TSV. For each wine include name and optionally quantity/single price when available from TSV . 184Be creative not always the same safe picks. Consider the specific ingredients, cooking method, and flavor profile of the dish when making your selection. 185Also for fancier/more expensive dishes consider more expensive wines. 186` 187 188const ( 189 recipeImageModel = openai.ImageModelGPTImage1_5 // dalle-3 is getting deprecated. 1.5 seems way better than 1. 190 // WebP is materially smaller for these recipe photos on mobile, and GPT image models support direct WebP output. 191 recipeImageOutputFormat = openai.ImageGenerateParamsOutputFormatWebP 192 recipeImageQuality = openai.ImageGenerateParamsQualityHigh 193 recipeImageSize = openai.ImageGenerateParamsSize1024x1024 194) 195 196func responseToShoppingList(ctx context.Context, resp *responses.Response) (*ShoppingList, error) { 197 slog.InfoContext(ctx, "API usage", responseUsageLogAttr(resp.Usage)) 198 var shoppingList ShoppingList 199 if err := json.Unmarshal([]byte(resp.OutputText()), &shoppingList); err != nil { 200 return nil, fmt.Errorf("failed to parse AI response: %w", err) 201 } 202 normalizeWineStyles(&shoppingList) 203 if strings.TrimSpace(resp.ID) == "" { 204 return nil, fmt.Errorf("failed to get response ID") 205 } 206 shoppingList.ResponseID = resp.ID 207 for i := range shoppingList.Recipes { 208 shoppingList.Recipes[i].ResponseID = shoppingList.ResponseID 209 } 210 211 return &shoppingList, nil 212} 213 214func scheme(schema map[string]any) responses.ResponseTextConfigParam { 215 return responses.ResponseTextConfigParam{ 216 Format: responses.ResponseFormatTextConfigUnionParam{ 217 OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ 218 Name: "recipes", 219 Schema: schema, // https://platform.openai.com/docs/guides/structured-outputs?example=structured-data 220 }, 221 }, 222 } 223} 224 225func (c *client) Regenerate(ctx context.Context, instructions []string, previousResponseID string) (*ShoppingList, error) { 226 if previousResponseID == "" { 227 return nil, fmt.Errorf("response ID is required for regeneration") 228 } 229 messages := cleanInstuctions(instructions) 230 231 params := responses.ResponseNewParams{ 232 Model: c.model, 233 PreviousResponseID: openai.String(previousResponseID), 234 // only new input 235 Input: responses.ResponseNewParamsInputUnion{ 236 OfInputItemList: messages, 237 }, 238 Store: openai.Bool(true), 239 Text: scheme(c.schema), 240 } 241 resp, err := c.oai.Responses.New(ctx, params) 242 if err != nil { 243 return nil, fmt.Errorf("failed to regenerate recipes: %w", err) 244 } 245 246 return responseToShoppingList(ctx, resp) 247} 248 249func (c *client) AskQuestion(ctx context.Context, question string, previousResponseID string) (*QuestionResponse, error) { 250 question = strings.TrimSpace(question) 251 if question == "" { 252 return nil, fmt.Errorf("question is required") 253 } 254 255 params := responses.ResponseNewParams{ 256 Model: c.model, 257 Instructions: openai.String("Answer the user's question about the recipe in plain text. Be concise and do not regenerate the full recipe or output JSON."), 258 Input: responses.ResponseNewParamsInputUnion{ 259 OfInputItemList: []responses.ResponseInputItemUnionParam{user(question)}, 260 }, 261 Store: openai.Bool(true), 262 } 263 if previousResponseID != "" { 264 params.PreviousResponseID = openai.String(previousResponseID) 265 } 266 resp, err := c.oai.Responses.New(ctx, params) 267 if err != nil { 268 return nil, fmt.Errorf("failed to answer question: %w", err) 269 } 270 answer := strings.TrimSpace(resp.OutputText()) 271 if answer == "" { 272 return nil, fmt.Errorf("empty response from model") 273 } 274 if strings.TrimSpace(resp.ID) == "" { 275 return nil, fmt.Errorf("failed to get response ID for question") 276 } 277 return &QuestionResponse{ 278 Answer: answer, 279 ResponseID: resp.ID, 280 }, nil 281} 282 283func (c *client) GenerateRecipeImage(ctx context.Context, recipe Recipe) (*GeneratedImage, error) { 284 prompt, err := buildRecipeImagePrompt(recipe) 285 if err != nil { 286 return nil, fmt.Errorf("failed to build recipe image prompt: %w", err) 287 } 288 289 resp, err := c.oai.Images.Generate(ctx, openai.ImageGenerateParams{ 290 Prompt: prompt, 291 Model: recipeImageModel, 292 N: openai.Int(1), 293 OutputFormat: recipeImageOutputFormat, 294 Quality: recipeImageQuality, 295 Size: recipeImageSize, 296 }) 297 if err != nil { 298 return nil, fmt.Errorf("failed to generate recipe image: %w", err) 299 } 300 301 slog.InfoContext(ctx, "API usage", imageUsageLogAttr(resp.Usage)) 302 if len(resp.Data) == 0 { 303 return nil, fmt.Errorf("image generation returned no images") 304 } 305 imageBody := strings.TrimSpace(resp.Data[0].B64JSON) 306 if imageBody == "" { 307 return nil, fmt.Errorf("image generation returned empty image data") 308 } 309 310 return &GeneratedImage{ 311 Body: base64.NewDecoder(base64.StdEncoding, strings.NewReader(imageBody)), 312 }, nil 313} 314 315func responseUsageLogAttr(usage responses.ResponseUsage) slog.Attr { 316 return slog.Group("usage", 317 slog.Int64("inputTokens", usage.InputTokens), 318 slog.Group("inputTokensDetails", 319 slog.Int64("cachedTokens", usage.InputTokensDetails.CachedTokens), 320 ), 321 slog.Int64("outputTokens", usage.OutputTokens), 322 slog.Group("outputTokensDetails", 323 slog.Int64("reasoningTokens", usage.OutputTokensDetails.ReasoningTokens), 324 ), 325 slog.Int64("totalTokens", usage.TotalTokens), 326 ) 327} 328 329func imageUsageLogAttr(usage openai.ImagesResponseUsage) slog.Attr { 330 return slog.Group("usage", 331 slog.Int64("inputTokens", usage.InputTokens), 332 slog.Group("inputTokensDetails", 333 slog.Int64("imageTokens", usage.InputTokensDetails.ImageTokens), 334 slog.Int64("textTokens", usage.InputTokensDetails.TextTokens), 335 ), 336 slog.Int64("outputTokens", usage.OutputTokens), 337 slog.Group("outputTokensDetails", 338 slog.Int64("imageTokens", usage.OutputTokensDetails.ImageTokens), 339 slog.Int64("textTokens", usage.OutputTokensDetails.TextTokens), 340 ), 341 slog.Int64("totalTokens", usage.TotalTokens), 342 ) 343} 344 345func (c *client) PickWine(ctx context.Context, recipe Recipe, wines []InputIngredient) (*WineSelection, error) { 346 prompt, err := buildWineSelectionPrompt(recipe, wines) 347 if err != nil { 348 return nil, fmt.Errorf("failed to build wine selection prompt: %w", err) 349 } 350 params := responses.ResponseNewParams{ 351 Model: c.wineModel, 352 Instructions: openai.String(winePrompt), 353 Input: responses.ResponseNewParamsInputUnion{ 354 OfInputItemList: []responses.ResponseInputItemUnionParam{user(prompt)}, 355 }, 356 Text: scheme(c.wineSchema), 357 } 358 resp, err := c.oai.Responses.New(ctx, params) 359 if err != nil { 360 return nil, fmt.Errorf("failed to pick wine: %w", err) 361 } 362 363 var selection WineSelection 364 if err := json.Unmarshal([]byte(resp.OutputText()), &selection); err != nil { 365 return nil, fmt.Errorf("failed to parse wine selection: %w", err) 366 } 367 return &selection, nil 368} 369 370func (c *client) GenerateRecipes(ctx context.Context, location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 371 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes) 372 if err != nil { 373 return nil, fmt.Errorf("failed to build recipe messages: %w", err) 374 } 375 376 params := responses.ResponseNewParams{ 377 Model: c.model, 378 Instructions: openai.String(systemMessage), 379 380 Input: responses.ResponseNewParamsInputUnion{ 381 OfInputItemList: messages, 382 }, 383 Store: openai.Bool(true), 384 Text: scheme(c.schema), 385 } 386 // should we stream. Can we pass past generation. 387 388 resp, err := c.oai.Responses.New(ctx, params) 389 if err != nil { 390 return nil, fmt.Errorf("failed to generate recipes: %w", err) 391 } 392 return responseToShoppingList(ctx, resp) 393} 394 395func user(msg string) responses.ResponseInputItemUnionParam { 396 return responses.ResponseInputItemParamOfMessage(msg, responses.EasyInputMessageRoleUser) 397} 398 399func buildRecipeImagePrompt(recipe Recipe) (string, error) { 400 var promptBuilder strings.Builder 401 fmt.Fprintf(&promptBuilder, "%s\n", recipeImagePromptInstructions) 402 fmt.Fprintf(&promptBuilder, "\n") 403 fmt.Fprintf(&promptBuilder, "Recipe:\n") 404 fmt.Fprintf(&promptBuilder, "%s\n", recipe.Title) 405 fmt.Fprintf(&promptBuilder, "%s\n", recipe.Description) 406 fmt.Fprintf(&promptBuilder, "Instructions:\n") 407 for _, ins := range recipe.Instructions { 408 fmt.Fprintf(&promptBuilder, "- %s\n", ins) 409 } 410 return promptBuilder.String(), nil 411} 412 413// similiar to image generation builder 414func buildWineSelectionPrompt(recipe Recipe, wines []InputIngredient) (string, error) { 415 var wineTSV strings.Builder 416 if err := InputIngredientsToTSV(wines, &wineTSV); err != nil { 417 return "", fmt.Errorf("failed to convert wines to TSV: %w", err) 418 } 419 420 var promptBuilder strings.Builder 421 fmt.Fprintf(&promptBuilder, "Recipe:\n") 422 fmt.Fprintf(&promptBuilder, "%s\n", recipe.Title) 423 fmt.Fprintf(&promptBuilder, "%s\n", recipe.Description) 424 fmt.Fprintf(&promptBuilder, "Instructions:\n") 425 for _, ins := range recipe.Instructions { 426 fmt.Fprintf(&promptBuilder, "- %s\n", ins) 427 } 428 fmt.Fprintf(&promptBuilder, "Existing drink pairing note: %s\n", recipe.DrinkPairing) 429 // add cost estimate when we believ it? 430 fmt.Fprintf(&promptBuilder, "\nCandidate wines TSV:\n%s", wineTSV.String()) 431 return promptBuilder.String(), nil 432} 433 434// buildRecipeMessages creates separate messages for the LLM to process more efficiently 435func (c *client) buildRecipeMessages(location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 436 var messages []responses.ResponseInputItemUnionParam 437 // constants we might make variable later 438 messages = append(messages, user("Prioritize ingredients that are in season for the current date and user's state location "+date.Format("January 2nd")+" in "+location.State+".")) 439 messages = append(messages, user("Default: each recipe should serve 2 people.")) 440 messages = append(messages, user("Default: generate 3 recipes")) 441 messages = append(messages, user("Default: prep and cook time under 1 hour")) 442 messages = append(messages, user("Default: cooking methods: oven, stove, grill, slow cooker")) 443 444 ingredientsMessage := fmt.Sprintf("%d ingredients available in TSV format with header.\n", len(saleIngredients)) 445 var buf strings.Builder 446 if err := InputIngredientsToTSV(saleIngredients, &buf); err != nil { 447 return nil, fmt.Errorf("failed to convert ingredients to TSV: %w", err) 448 } 449 ingredientsMessage += buf.String() 450 messages = append(messages, user(ingredientsMessage)) 451 452 // Previously cooked recipes to avoid (if any). 453 if len(lastRecipes) > 0 { 454 var prevRecipesMsg strings.Builder 455 prevRecipesMsg.WriteString("Avoid recipes similar to these previously cooked:\n") 456 for _, recipe := range lastRecipes { 457 fmt.Fprintf(&prevRecipesMsg, "%s\n", recipe) 458 } 459 messages = append(messages, user(prevRecipesMsg.String())) 460 } 461 462 // Additional user instructions (if any) 463 464 messages = append(messages, cleanInstuctions(instructions)...) 465 466 return messages, nil 467} 468 469func (c *client) Ready(ctx context.Context) error { 470 // more CORRECT to do a very simple response request with allowed tokens 1 but this seems cheaper 471 // https://chatgpt.com/share/6984da16-ff88-8009-8486-4e0479ac6a01 472 // could only do it once to ensure startup 473 _, err := c.oai.Models.List(ctx) 474 return err 475} 476 477func cleanInstuctions(instructions []string) []responses.ResponseInputItemUnionParam { 478 var responses []responses.ResponseInputItemUnionParam 479 for _, i := range instructions { 480 i = strings.TrimSpace(i) 481 if i == "" { 482 continue 483 } 484 responses = append(responses, user(i)) 485 } 486 return responses 487} 488 489func normalizeWineStyles(shoppingList *ShoppingList) { 490 if shoppingList == nil { 491 return 492 } 493 for i := range shoppingList.Recipes { 494 shoppingList.Recipes[i].WineStyles = normalizeRecipeWineStyles(shoppingList.Recipes[i].WineStyles) 495 } 496} 497 498func normalizeRecipeWineStyles(styles []string) []string { 499 if len(styles) == 0 { 500 return nil 501 } 502 cleaned := make([]string, 0, min(len(styles), 2)) 503 seen := map[string]struct{}{} 504 for _, style := range styles { 505 normalized := normalizeWineStyle(style) 506 if normalized == "" { 507 continue 508 } 509 key := strings.ToLower(normalized) 510 if _, ok := seen[key]; ok { 511 continue 512 } 513 seen[key] = struct{}{} 514 cleaned = append(cleaned, normalized) 515 if len(cleaned) == 2 { 516 break 517 } 518 } 519 if len(cleaned) == 0 { 520 return nil 521 } 522 return cleaned 523} 524 525func normalizeWineStyle(style string) string { 526 style = strings.TrimSpace(style) 527 if style == "" { 528 return "" 529 } 530 if idx := strings.IndexAny(style, "(["); idx >= 0 { 531 style = strings.TrimSpace(style[:idx]) 532 } 533 style = strings.TrimSpace(strings.TrimSuffix(style, ".")) 534 if style == "" { 535 return "" 536 } 537 return strings.Join(strings.Fields(style), " ") 538}