ai cooking
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}