ai cooking
0
fork

Configure Feed

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

Takefeedback (#484)

* might work

* pipefeedbck back into recipe generation

* raise the bar

* regenerating but losing discraded

* test save discarded

* parallize saving recipes

* keep mistakes out of description

* save recipes shouldn't mutate recipes and unexport it

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
62d5408d 3b75f96d

+629 -76
+1 -1
docs/cache-layout.md
··· 29 29 | `shoppinglist/` | JSON `ai.ShoppingList` keyed by shopping hash | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`FromCache`) | 30 30 | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash (staples) or by wine style/date/location hash (wine candidate cache) | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | 31 31 | `params/` | JSON `generatorParams` keyed by shopping hash; params no longer embed the resolved staple filter list | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 32 - | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveRecipes`) | `internal/recipes/io.go` (`SingleFromCache`) | 32 + | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`SingleFromCache`) | 33 33 | `recipe_images/` | WebP bytes for single-recipe dish images keyed by recipe hash in the dedicated `recipe-images` cache backend | `internal/recipes/image.go` (`SaveRecipeImage`) via `internal/recipes/server.go` (`POST /recipe/{hash}/image`) | `internal/recipes/image.go` (`RecipeImageFromCache`, `RecipeImageExists`) via `internal/recipes/server.go` (`GET /recipe/{hash}/image`, `handleSingle`) | 34 34 | `wine_recommendations/` | Plain text wine recommendation keyed by recipe hash | `internal/recipes/wine.go` (`SaveWine`) via `internal/recipes/server.go` (`handleWine`) | `internal/recipes/wine.go` (`WineFromCache`) via `internal/recipes/server.go` (`handleWine`) | 35 35 | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) |
+5
internal/ai/client.go
··· 84 84 type ShoppingList struct { 85 85 ConversationID string `json:"conversation_id,omitempty" jsonschema:"-"` 86 86 Recipes []Recipe `json:"recipes" jsonschema:"required"` 87 + Discarded []Recipe `json:"-" jsonschema:"-"` 87 88 } 88 89 89 90 type WineSelection struct { ··· 230 231 resp, err := client.Responses.New(ctx, params) 231 232 if err != nil { 232 233 return nil, fmt.Errorf("failed to regenerate recipes: %w", err) 234 + } 235 + 236 + if resp.Conversation.ID != conversationID { 237 + return nil, fmt.Errorf("conversation ID mismatch in regeneration response") 233 238 } 234 239 235 240 return responseToShoppingList(ctx, resp)
+1 -3
internal/recipes/admin_page_test.go
··· 43 43 DrinkPairing: "Pinot Grigio", 44 44 }, 45 45 } 46 - if err := rio.SaveRecipes(t.Context(), recipes, "origin-hash"); err != nil { 47 - t.Fatalf("save recipes: %v", err) 48 - } 46 + saveRecipesForOrigin(t, rio, "origin-hash", recipes...) 49 47 50 48 newestHash := recipes[0].ComputeHash() 51 49 olderHash := recipes[1].ComputeHash()
+114 -11
internal/recipes/generator.go
··· 47 47 SaveCritique(ctx context.Context, hash string, critique *ai.RecipeCritique) error 48 48 } 49 49 50 + const minimumRecipeCritiqueScore = 8 51 + 50 52 type Generator struct { 51 53 config *config.Config 52 54 aiClient aiClient ··· 153 155 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID) 154 156 // these should both always be true. Warn if not because its a caching bug? 155 157 instructions := []string{p.Instructions} 158 + // TODO give more guidnance on how many recipes to generate here 156 159 for _, dismissed := range p.Dismissed { 157 160 instructions = append(instructions, "Passed on "+dismissed.Title) 158 161 } ··· 164 167 if err != nil { 165 168 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) 166 169 } 167 - if err := g.cacheRecipeCritiques(ctx, shoppingList.Recipes); err != nil { 168 - return nil, fmt.Errorf("failed to cache recipe critiques: %w", err) 170 + shoppingList, err = g.critiqueAndMaybeRetry(ctx, shoppingList) 171 + if err != nil { 172 + return nil, err 169 173 } 170 174 // Include saved recipes in the shopping list 171 175 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) ··· 185 189 if err != nil { 186 190 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) 187 191 } 188 - if err := g.cacheRecipeCritiques(ctx, shoppingList.Recipes); err != nil { 189 - return nil, fmt.Errorf("failed to cache recipe critiques: %w", err) 192 + shoppingList, err = g.critiqueAndMaybeRetry(ctx, shoppingList) 193 + if err != nil { 194 + return nil, err 190 195 } 191 196 // how to pipe this back to ai client? should ai client hjave its own critiquer or do we just call regenerate once? 192 197 ··· 301 306 return lo.Uniq(titles) 302 307 } 303 308 304 - func (g *Generator) cacheRecipeCritiques(ctx context.Context, recipes []ai.Recipe) error { 309 + type recipeCritiqueResult struct { 310 + Recipe *ai.Recipe // just here so we can give the model the title 311 + Critique *ai.RecipeCritique 312 + } 313 + 314 + func (g *Generator) critiqueAndMaybeRetry(ctx context.Context, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 315 + results, err := g.cacheRecipeCritiques(ctx, shoppingList.Recipes) 316 + if err != nil { 317 + return nil, fmt.Errorf("failed to cache recipe critiques: %w", err) 318 + } 319 + var garbage []recipeCritiqueResult 320 + var good []ai.Recipe 321 + for _, result := range results { 322 + if result.Critique.OverallScore >= minimumRecipeCritiqueScore { 323 + slog.InfoContext(ctx, "acceptable", "hash", result.Recipe.ComputeHash(), "title", result.Recipe.Title, "score", result.Critique.OverallScore) 324 + 325 + good = append(good, *result.Recipe) 326 + } else { 327 + // if there are no issues should we still retry? wasted of tokens 328 + slog.InfoContext(ctx, "low scoring recipe", "hash", result.Recipe.ComputeHash(), "title", result.Recipe.Title, "score", result.Critique.OverallScore) 329 + garbage = append(garbage, result) 330 + } 331 + } 332 + if len(garbage) == 0 { 333 + return shoppingList, nil 334 + } 335 + slog.InfoContext(ctx, "regenerating recipes based on critique feedback", "garbage_count", len(garbage), "good_count", len(good)) 336 + 337 + // store the garbage ones for reference 338 + 339 + if strings.TrimSpace(shoppingList.ConversationID) == "" { 340 + return nil, fmt.Errorf("conversation ID is required for critique retry") 341 + } 342 + 343 + shoppingList, err = g.aiClient.Regenerate(ctx, critiqueRetryInstructions(garbage), shoppingList.ConversationID) 344 + if err != nil { 345 + return nil, fmt.Errorf("failed to regenerate recipes from critique feedback: %w", err) 346 + } 347 + shoppingList.Recipes = append(shoppingList.Recipes, good...) 348 + shoppingList.Discarded = lo.Map(garbage, func(result recipeCritiqueResult, _ int) ai.Recipe { 349 + return *result.Recipe 350 + }) 351 + 352 + // async as this is just debug not retrying twice yet. 353 + if _, err := g.cacheRecipeCritiques(ctx, shoppingList.Recipes); err != nil { 354 + return nil, fmt.Errorf("failed to cache recipe critiques: %w", err) 355 + } 356 + return shoppingList, nil 357 + } 358 + 359 + func (g *Generator) cacheRecipeCritiques(ctx context.Context, recipes []ai.Recipe) ([]recipeCritiqueResult, error) { 305 360 if g.critiquer == nil || g.cio == nil { 306 361 // yuck refactor tests to make this alway present 307 - return nil 362 + return nil, nil 308 363 } 309 - _, err := parallelism.MapWithErrors(recipes, func(recipe ai.Recipe) (int, error) { 364 + return parallelism.MapWithErrors(recipes, func(recipe ai.Recipe) (recipeCritiqueResult, error) { 310 365 hash := recipe.ComputeHash() 311 366 critique, err := g.critiquer.CritiqueRecipe(ctx, recipe) 312 367 if err != nil { 313 368 slog.ErrorContext(ctx, "failed to critique recipe", "recipe", recipe.Title, "hash", hash, "error", err) 314 - return 0, fmt.Errorf("critique recipe %q (%s): %w", recipe.Title, hash, err) 369 + return recipeCritiqueResult{}, fmt.Errorf("critique recipe %q (%s): %w", recipe.Title, hash, err) 315 370 } 371 + // should we background the saving of this? too fast to matter? 316 372 if err := g.cio.SaveCritique(ctx, hash, critique); err != nil { 317 373 slog.ErrorContext(ctx, "failed to cache recipe critique", "recipe", recipe.Title, "hash", hash, "error", err) 318 - return 0, fmt.Errorf("cache critique for recipe %q (%s): %w", recipe.Title, hash, err) 374 + return recipeCritiqueResult{}, fmt.Errorf("cache critique for recipe %q (%s): %w", recipe.Title, hash, err) 319 375 } 320 - return 0, nil 376 + return recipeCritiqueResult{ 377 + Recipe: &recipe, 378 + Critique: critique, 379 + }, nil 321 380 }) 322 - return err 381 + } 382 + 383 + func critiqueRetryInstructions(results []recipeCritiqueResult) []string { 384 + // first shot really wanted to explain corrections in description. Should we add another debug field for that? 385 + revise := fmt.Sprintf("Revise and return exactly %d recipes as replacements for the low-scoring recipes listed below. Description should focus on selling the dish not these corrections", len(results)) 386 + instructions := []string{revise} 387 + for _, result := range results { 388 + // do we care about summar or is it just a wast of tokens 389 + instructions = append(instructions, fmt.Sprintf( 390 + "Recipe %q scored %d/10.\n Issues: %s\n Suggested fixes: %s", 391 + result.Recipe.Title, 392 + result.Critique.OverallScore, 393 + // strings.TrimSpace(result.Critique.Summary), 394 + formatCritiqueIssues(result.Critique.Issues), 395 + formatSuggestedFixes(result.Critique.SuggestedFixes), 396 + )) 397 + } 398 + return instructions 399 + } 400 + 401 + func formatCritiqueIssues(issues []ai.RecipeCritiqueIssue) string { 402 + if len(issues) == 0 { 403 + return "none listed." 404 + } 405 + parts := make([]string, 0, len(issues)) 406 + for _, issue := range issues { 407 + parts = append(parts, fmt.Sprintf("[%s/%s] %s", issue.Category, issue.Severity, strings.TrimSpace(issue.Detail))) 408 + } 409 + return strings.Join(parts, "; ") 410 + } 411 + 412 + func formatSuggestedFixes(fixes []string) string { 413 + if len(fixes) == 0 { 414 + return "none listed." 415 + } 416 + trimmed := make([]string, 0, len(fixes)) 417 + for _, fix := range fixes { 418 + if fix = strings.TrimSpace(fix); fix != "" { 419 + trimmed = append(trimmed, fix) 420 + } 421 + } 422 + if len(trimmed) == 0 { 423 + return "none listed." 424 + } 425 + return strings.Join(trimmed, "; ") 323 426 }
+390 -1
internal/recipes/generator_test.go
··· 31 31 shoppingList *ai.ShoppingList 32 32 } 33 33 34 + type sequenceAIClient struct { 35 + mu sync.Mutex 36 + generateCalls int 37 + generateInstructions [][]string 38 + regenerateCalls int 39 + regenerateInstructions [][]string 40 + regenerateConversation []string 41 + generateResponses []*ai.ShoppingList 42 + regenerateResponses []*ai.ShoppingList 43 + } 44 + 34 45 type captureCritiquer struct { 35 46 mu sync.Mutex 36 47 err error 37 48 recipes []ai.Recipe 49 + fn func(ai.Recipe) (*ai.RecipeCritique, error) 38 50 } 39 51 40 52 type captureWineStaplesProvider struct { ··· 131 143 return nil 132 144 } 133 145 146 + func (c *sequenceAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 147 + c.mu.Lock() 148 + defer c.mu.Unlock() 149 + 150 + c.generateCalls++ 151 + c.generateInstructions = append(c.generateInstructions, append([]string(nil), instructions...)) 152 + if len(c.generateResponses) == 0 { 153 + return &ai.ShoppingList{}, nil 154 + } 155 + resp := c.generateResponses[0] 156 + c.generateResponses = c.generateResponses[1:] 157 + return resp, nil 158 + } 159 + 160 + func (c *sequenceAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 161 + c.mu.Lock() 162 + defer c.mu.Unlock() 163 + 164 + c.regenerateCalls++ 165 + c.regenerateInstructions = append(c.regenerateInstructions, append([]string(nil), newinstructions...)) 166 + c.regenerateConversation = append(c.regenerateConversation, conversationID) 167 + if len(c.regenerateResponses) == 0 { 168 + return &ai.ShoppingList{}, nil 169 + } 170 + resp := c.regenerateResponses[0] 171 + c.regenerateResponses = c.regenerateResponses[1:] 172 + return resp, nil 173 + } 174 + 175 + func (c *sequenceAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 176 + panic("unexpected call to AskQuestion") 177 + } 178 + 179 + func (c *sequenceAIClient) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 180 + panic("unexpected call to GenerateRecipeImage") 181 + } 182 + 183 + func (c *sequenceAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 184 + panic("unexpected call to PickWine") 185 + } 186 + 187 + func (c *sequenceAIClient) Ready(ctx context.Context) error { 188 + return nil 189 + } 190 + 134 191 func (c *captureCritiquer) CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) { 135 192 c.mu.Lock() 136 193 c.recipes = append(c.recipes, recipe) ··· 138 195 if c.err != nil { 139 196 return nil, c.err 140 197 } 198 + if c.fn != nil { 199 + return c.fn(recipe) 200 + } 141 201 return &ai.RecipeCritique{ 142 202 SchemaVersion: "recipe-critique-v1", 143 - OverallScore: 7, 203 + OverallScore: minimumRecipeCritiqueScore, 144 204 Summary: "Solid draft.", 145 205 Strengths: []string{"clear direction"}, 146 206 Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "timing", Detail: "Timing could be tighter."}}, ··· 431 491 } 432 492 if len(critiquer.recipes) != 1 || critiquer.recipes[0].Title != "Brand New Dinner" { 433 493 t.Fatalf("expected only the newly generated recipe to be critiqued, got %+v", critiquer.recipes) 494 + } 495 + } 496 + 497 + func TestGenerateRecipes_RetriesLowScoringGeneratedRecipesOnce(t *testing.T) { 498 + initial := ai.Recipe{Title: "Weak Dinner", Description: "Needs work"} 499 + retried := ai.Recipe{Title: "Better Dinner", Description: "Improved"} 500 + 501 + cacheStore := cache.NewFileCache(t.TempDir()) 502 + io := IO(cacheStore) 503 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 504 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 505 + t.Fatalf("failed to seed ingredients cache: %v", err) 506 + } 507 + 508 + aiStub := &sequenceAIClient{ 509 + generateResponses: []*ai.ShoppingList{{ 510 + ConversationID: "conv-initial", 511 + Recipes: []ai.Recipe{initial}, 512 + }}, 513 + regenerateResponses: []*ai.ShoppingList{{ 514 + ConversationID: "conv-retried", 515 + Recipes: []ai.Recipe{retried}, 516 + }}, 517 + } 518 + critiquer := &captureCritiquer{ 519 + fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 520 + switch recipe.Title { 521 + case "Weak Dinner": 522 + return &ai.RecipeCritique{ 523 + SchemaVersion: "recipe-critique-v1", 524 + OverallScore: 6, 525 + Summary: "Needs a cleaner finish.", 526 + Strengths: []string{"solid idea"}, 527 + Issues: []ai.RecipeCritiqueIssue{{Severity: "high", Category: "clarity", Detail: "The sauce step is vague."}}, 528 + SuggestedFixes: []string{"clarify when to reduce the sauce"}, 529 + }, nil 530 + case "Better Dinner": 531 + return &ai.RecipeCritique{ 532 + SchemaVersion: "recipe-critique-v1", 533 + OverallScore: 8, 534 + Summary: "Ready to cook.", 535 + Strengths: []string{"clear direction"}, 536 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "timing", Detail: "Could shave a minute or two."}}, 537 + SuggestedFixes: []string{"tighten the simmer time"}, 538 + }, nil 539 + default: 540 + t.Fatalf("unexpected recipe critique request for %q", recipe.Title) 541 + return nil, nil 542 + } 543 + }, 544 + } 545 + g := &Generator{ 546 + io: io, 547 + cio: io, 548 + aiClient: aiStub, 549 + critiquer: critiquer, 550 + } 551 + 552 + got, err := g.GenerateRecipes(t.Context(), params) 553 + if err != nil { 554 + t.Fatalf("GenerateRecipes returned error: %v", err) 555 + } 556 + if got == nil || len(got.Recipes) != 1 || got.Recipes[0].Title != "Better Dinner" { 557 + t.Fatalf("expected retried shopping list, got %+v", got) 558 + } 559 + if got.ConversationID != "conv-retried" { 560 + t.Fatalf("expected final conversation ID %q, got %q", "conv-retried", got.ConversationID) 561 + } 562 + if aiStub.regenerateCalls != 1 { 563 + t.Fatalf("expected one critique-driven regenerate call, got %d", aiStub.regenerateCalls) 564 + } 565 + wantInstructions := []string{ 566 + "Revise and return exactly 1 recipes as replacements for the low-scoring recipes listed below. Description should focus on selling the dish not these corrections", 567 + "Recipe \"Weak Dinner\" scored 6/10.\n Issues: [clarity/high] The sauce step is vague.\n Suggested fixes: clarify when to reduce the sauce", 568 + } 569 + if got := aiStub.regenerateInstructions[0]; !slices.Equal(got, wantInstructions) { 570 + t.Fatalf("unexpected critique retry instructions: got %v want %v", got, wantInstructions) 571 + } 572 + if got := aiStub.regenerateConversation; !slices.Equal(got, []string{"conv-initial"}) { 573 + t.Fatalf("unexpected critique retry conversation IDs: got %v", got) 574 + } 575 + if len(critiquer.recipes) != 2 { 576 + t.Fatalf("expected two critique passes, got %d", len(critiquer.recipes)) 577 + } 578 + if critique, err := io.CritiqueFromCache(t.Context(), retried.ComputeHash()); err != nil { 579 + t.Fatalf("expected cached critique for retried recipe: %v", err) 580 + } else if critique.OverallScore != 8 { 581 + t.Fatalf("expected final critique score 8, got %d", critique.OverallScore) 582 + } 583 + } 584 + 585 + func TestGenerateRecipes_RetryKeepsHighScoringRecipes(t *testing.T) { 586 + weak := ai.Recipe{Title: "Weak Dinner", Description: "Needs work"} 587 + good := ai.Recipe{Title: "Solid Dinner", Description: "Already fine"} 588 + retried := ai.Recipe{Title: "Better Dinner", Description: "Improved"} 589 + 590 + cacheStore := cache.NewFileCache(t.TempDir()) 591 + io := IO(cacheStore) 592 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 593 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 594 + t.Fatalf("failed to seed ingredients cache: %v", err) 595 + } 596 + 597 + aiStub := &sequenceAIClient{ 598 + generateResponses: []*ai.ShoppingList{{ 599 + ConversationID: "conv-initial", 600 + Recipes: []ai.Recipe{weak, good}, 601 + }}, 602 + regenerateResponses: []*ai.ShoppingList{{ 603 + ConversationID: "conv-retried", 604 + Recipes: []ai.Recipe{retried}, 605 + }}, 606 + } 607 + critiquer := &captureCritiquer{ 608 + fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 609 + switch recipe.Title { 610 + case "Weak Dinner": 611 + return &ai.RecipeCritique{ 612 + SchemaVersion: "recipe-critique-v1", 613 + OverallScore: 6, 614 + Summary: "Needs a cleaner finish.", 615 + Strengths: []string{"solid idea"}, 616 + Issues: []ai.RecipeCritiqueIssue{{Severity: "high", Category: "clarity", Detail: "The sauce step is vague."}}, 617 + SuggestedFixes: []string{"clarify when to reduce the sauce"}, 618 + }, nil 619 + case "Solid Dinner", "Better Dinner": 620 + return &ai.RecipeCritique{ 621 + SchemaVersion: "recipe-critique-v1", 622 + OverallScore: 8, 623 + Summary: "Ready to cook.", 624 + Strengths: []string{"clear direction"}, 625 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "timing", Detail: "Could shave a minute or two."}}, 626 + SuggestedFixes: []string{"tighten the simmer time"}, 627 + }, nil 628 + default: 629 + t.Fatalf("unexpected recipe critique request for %q", recipe.Title) 630 + return nil, nil 631 + } 632 + }, 633 + } 634 + g := &Generator{ 635 + io: io, 636 + cio: io, 637 + aiClient: aiStub, 638 + critiquer: critiquer, 639 + } 640 + 641 + got, err := g.GenerateRecipes(t.Context(), params) 642 + if err != nil { 643 + t.Fatalf("GenerateRecipes returned error: %v", err) 644 + } 645 + if got == nil || len(got.Recipes) != 2 { 646 + t.Fatalf("expected retried recipe plus preserved good recipe, got %+v", got) 647 + } 648 + if got.Recipes[0].Title != "Better Dinner" || got.Recipes[1].Title != "Solid Dinner" { 649 + t.Fatalf("unexpected recipe order after partial retry: %+v", got.Recipes) 650 + } 651 + } 652 + 653 + func TestGenerateRecipes_DoesNotRetryWhenCritiquesMeetThreshold(t *testing.T) { 654 + steady := ai.Recipe{Title: "Steady Dinner", Description: "Good enough"} 655 + 656 + cacheStore := cache.NewFileCache(t.TempDir()) 657 + io := IO(cacheStore) 658 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 659 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 660 + t.Fatalf("failed to seed ingredients cache: %v", err) 661 + } 662 + 663 + aiStub := &sequenceAIClient{ 664 + generateResponses: []*ai.ShoppingList{{ 665 + ConversationID: "conv-stable", 666 + Recipes: []ai.Recipe{steady}, 667 + }}, 668 + } 669 + g := &Generator{ 670 + io: io, 671 + cio: io, 672 + aiClient: aiStub, 673 + critiquer: &captureCritiquer{}, 674 + } 675 + 676 + got, err := g.GenerateRecipes(t.Context(), params) 677 + if err != nil { 678 + t.Fatalf("GenerateRecipes returned error: %v", err) 679 + } 680 + if got == nil || len(got.Recipes) != 1 || got.Recipes[0].Title != "Steady Dinner" { 681 + t.Fatalf("unexpected shopping list: %+v", got) 682 + } 683 + if aiStub.regenerateCalls != 0 { 684 + t.Fatalf("expected no critique-driven regenerate calls, got %d", aiStub.regenerateCalls) 685 + } 686 + } 687 + 688 + func TestGenerateRecipes_RegenerateRetriesLowScoringRecipesOnce(t *testing.T) { 689 + alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 690 + initial := ai.Recipe{Title: "Needs Work", Description: "First pass"} 691 + retried := ai.Recipe{Title: "Ready Now", Description: "Second pass"} 692 + 693 + cacheStore := cache.NewFileCache(t.TempDir()) 694 + io := IO(cacheStore) 695 + aiStub := &sequenceAIClient{ 696 + regenerateResponses: []*ai.ShoppingList{ 697 + { 698 + ConversationID: "conv-first-pass", 699 + Recipes: []ai.Recipe{initial}, 700 + }, 701 + { 702 + ConversationID: "conv-second-pass", 703 + Recipes: []ai.Recipe{retried}, 704 + }, 705 + }, 706 + } 707 + critiquer := &captureCritiquer{ 708 + fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 709 + switch recipe.Title { 710 + case "Needs Work": 711 + return &ai.RecipeCritique{ 712 + SchemaVersion: "recipe-critique-v1", 713 + OverallScore: 5, 714 + Summary: "Too loose for a weeknight cook.", 715 + Strengths: []string{"promising flavors"}, 716 + Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "timing", Detail: "Cooking times are inconsistent."}}, 717 + SuggestedFixes: []string{"make the timing consistent"}, 718 + }, nil 719 + case "Ready Now": 720 + return &ai.RecipeCritique{ 721 + SchemaVersion: "recipe-critique-v1", 722 + OverallScore: 8, 723 + Summary: "Clear and usable.", 724 + Strengths: []string{"better pacing"}, 725 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "presentation", Detail: "Could add garnish detail."}}, 726 + SuggestedFixes: []string{"mention a finishing garnish"}, 727 + }, nil 728 + default: 729 + t.Fatalf("unexpected recipe critique request for %q", recipe.Title) 730 + return nil, nil 731 + } 732 + }, 733 + } 734 + g := &Generator{ 735 + io: io, 736 + cio: io, 737 + aiClient: aiStub, 738 + critiquer: critiquer, 739 + } 740 + 741 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 742 + params.ConversationID = "conv-original" 743 + params.Instructions = "make it vegetarian" 744 + params.Saved = []ai.Recipe{alreadySaved} 745 + 746 + got, err := g.GenerateRecipes(t.Context(), params) 747 + if err != nil { 748 + t.Fatalf("GenerateRecipes returned error: %v", err) 749 + } 750 + if got == nil || len(got.Recipes) != 2 { 751 + t.Fatalf("expected regenerated list plus saved recipe, got %+v", got) 752 + } 753 + if got.Recipes[0].Title != "Ready Now" || got.Recipes[1].Title != "Already Saved" { 754 + t.Fatalf("unexpected recipe order after critique retry: %+v", got.Recipes) 755 + } 756 + if got.ConversationID != "conv-second-pass" { 757 + t.Fatalf("expected final conversation ID %q, got %q", "conv-second-pass", got.ConversationID) 758 + } 759 + if aiStub.regenerateCalls != 2 { 760 + t.Fatalf("expected initial regenerate plus one critique retry, got %d calls", aiStub.regenerateCalls) 761 + } 762 + if got := aiStub.regenerateConversation; !slices.Equal(got, []string{"conv-original", "conv-first-pass"}) { 763 + t.Fatalf("unexpected regenerate conversation IDs: got %v", got) 764 + } 765 + wantRetryInstructions := []string{ 766 + "Revise and return exactly 1 recipes as replacements for the low-scoring recipes listed below. Description should focus on selling the dish not these corrections", 767 + "Recipe \"Needs Work\" scored 5/10.\n Issues: [timing/medium] Cooking times are inconsistent.\n Suggested fixes: make the timing consistent", 768 + } 769 + if got := aiStub.regenerateInstructions[1]; !slices.Equal(got, wantRetryInstructions) { 770 + t.Fatalf("unexpected critique retry instructions: got %v want %v", got, wantRetryInstructions) 771 + } 772 + } 773 + 774 + func TestGenerateRecipes_RetriesAtMostOnceEvenIfRetryStillScoresLow(t *testing.T) { 775 + initial := ai.Recipe{Title: "First Try", Description: "Low score"} 776 + retried := ai.Recipe{Title: "Second Try", Description: "Still low"} 777 + 778 + cacheStore := cache.NewFileCache(t.TempDir()) 779 + io := IO(cacheStore) 780 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 781 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 782 + t.Fatalf("failed to seed ingredients cache: %v", err) 783 + } 784 + 785 + aiStub := &sequenceAIClient{ 786 + generateResponses: []*ai.ShoppingList{{ 787 + ConversationID: "conv-one", 788 + Recipes: []ai.Recipe{initial}, 789 + }}, 790 + regenerateResponses: []*ai.ShoppingList{{ 791 + ConversationID: "conv-two", 792 + Recipes: []ai.Recipe{retried}, 793 + }}, 794 + } 795 + critiquer := &captureCritiquer{ 796 + fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 797 + return &ai.RecipeCritique{ 798 + SchemaVersion: "recipe-critique-v1", 799 + OverallScore: 6, 800 + Summary: "Still not ready.", 801 + Strengths: []string{"salvageable"}, 802 + Issues: []ai.RecipeCritiqueIssue{{Severity: "high", Category: "cookability", Detail: "The method still has gaps."}}, 803 + SuggestedFixes: []string{"rewrite the method more clearly"}, 804 + }, nil 805 + }, 806 + } 807 + g := &Generator{ 808 + io: io, 809 + cio: io, 810 + aiClient: aiStub, 811 + critiquer: critiquer, 812 + } 813 + 814 + got, err := g.GenerateRecipes(t.Context(), params) 815 + if err != nil { 816 + t.Fatalf("GenerateRecipes returned error: %v", err) 817 + } 818 + if got == nil || len(got.Recipes) != 1 || got.Recipes[0].Title != "Second Try" { 819 + t.Fatalf("unexpected retried shopping list: %+v", got) 820 + } 821 + if aiStub.regenerateCalls != 1 { 822 + t.Fatalf("expected exactly one critique-driven retry, got %d", aiStub.regenerateCalls) 434 823 } 435 824 } 436 825
+21 -16
internal/recipes/io.go
··· 10 10 "careme/internal/ai" 11 11 "careme/internal/cache" 12 12 "careme/internal/kroger" 13 + "careme/internal/parallelism" 13 14 "careme/internal/recipes/feedback" 14 15 15 16 "github.com/samber/lo" ··· 124 125 return rio.Cache.Put(ctx, ingredientsCachePrefix+hash, string(ingredientsJSON), cache.Unconditional()) 125 126 } 126 127 127 - // exported for backfilling 128 - func (rio recipeio) SaveRecipes(ctx context.Context, recipes []ai.Recipe, originHash string) error { 128 + func (rio recipeio) saveRecipes(ctx context.Context, recipes []ai.Recipe) error { 129 129 // Save each recipe separately by its hash (could skip ones that are saved?) 130 - var errs []error 131 - for i := range recipes { 132 - recipe := &recipes[i] 133 - recipe.OriginHash = originHash 134 - hash := recipe.ComputeHash() 135 - 136 - recipeJSON := lo.Must(json.Marshal(recipe)) 130 + _, err := parallelism.MapWithErrors(recipes, func(r ai.Recipe) (bool, error) { 131 + hash := r.ComputeHash() 132 + recipeJSON := lo.Must(json.Marshal(r)) 137 133 if err := rio.Cache.Put(ctx, recipeCachePrefix+hash, string(recipeJSON), cache.IfNoneMatch()); err != nil { 138 134 if errors.Is(err, cache.ErrAlreadyExists) { 139 - continue 135 + return false, nil 140 136 } 141 - slog.ErrorContext(ctx, "failed to cache individual recipe", "recipe", recipe.Title, "error", err) 142 - errs = append(errs, fmt.Errorf("error saving %s, %w", hash, err)) 137 + slog.ErrorContext(ctx, "failed to cache individual recipe", "recipe", r.Title, "error", err) 138 + return false, err 143 139 } 144 - slog.InfoContext(ctx, "stored recipe", "title", recipe.Title, "hash", hash) 145 - } 146 - return errors.Join(errs...) 140 + slog.InfoContext(ctx, "stored recipe", "title", r.Title, "hash", hash) 141 + return true, nil 142 + }) 143 + return err 147 144 } 148 145 149 146 var ErrAlreadyExists = errors.New("already exists") ··· 161 158 } 162 159 163 160 func (rio recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, hash string) error { 161 + for i := range shoppingList.Recipes { 162 + shoppingList.Recipes[i].OriginHash = hash 163 + } 164 + for i := range shoppingList.Discarded { 165 + shoppingList.Discarded[i].OriginHash = hash 166 + } 167 + 164 168 // Save each recipe separately by its hash 165 - if err := rio.SaveRecipes(ctx, shoppingList.Recipes, hash); err != nil { 169 + if err := rio.saveRecipes(ctx, append(shoppingList.Recipes, shoppingList.Discarded...)); err != nil { 166 170 return err 167 171 } 168 172 // we could actually nuke out the rest of recipe and lazily load but not yet 173 + shoppingList.Discarded = nil 169 174 shoppingJSON := lo.Must(json.Marshal(shoppingList)) 170 175 if err := rio.Cache.Put(ctx, ShoppingListCachePrefix+hash, string(shoppingJSON), cache.Unconditional()); err != nil { 171 176 slog.ErrorContext(ctx, "failed to cache shopping list document", "hash", hash, "error", err)
+56
internal/recipes/io_test.go
··· 114 114 } 115 115 } 116 116 117 + func TestSaveShoppingList_SavesDiscardedRecipesSeparately(t *testing.T) { 118 + tmpDir := t.TempDir() 119 + cacheStore := cache.NewFileCache(tmpDir) 120 + rio := IO(cacheStore) 121 + 122 + kept := ai.Recipe{ 123 + Title: "One Pan Chicken", 124 + Description: "Simple weeknight meal", 125 + Ingredients: []ai.Ingredient{{Name: "Chicken", Quantity: "1 lb", Price: "5.99"}}, 126 + Instructions: []string{"Prep ingredients", "Cook chicken"}, 127 + Health: "Balanced", 128 + DrinkPairing: "Chardonnay", 129 + } 130 + discarded := ai.Recipe{ 131 + Title: "Mushy Pasta", 132 + Description: "Too vague to keep", 133 + Ingredients: []ai.Ingredient{{Name: "Pasta", Quantity: "1 lb", Price: "1.99"}}, 134 + Instructions: []string{"Cook until done somehow"}, 135 + Health: "Heavy", 136 + DrinkPairing: "None", 137 + } 138 + hash := "test-hash" 139 + list := &ai.ShoppingList{ 140 + ConversationID: "conversation-123", 141 + Recipes: []ai.Recipe{kept}, 142 + Discarded: []ai.Recipe{discarded}, 143 + } 144 + 145 + if err := rio.SaveShoppingList(t.Context(), list, hash); err != nil { 146 + t.Fatalf("SaveShoppingList failed: %v", err) 147 + } 148 + 149 + if len(list.Discarded) != 0 { 150 + t.Fatalf("expected SaveShoppingList to clear discarded recipes after persisting, got %+v", list.Discarded) 151 + } 152 + 153 + stored, err := rio.SingleFromCache(t.Context(), discarded.ComputeHash()) 154 + if err != nil { 155 + t.Fatalf("expected discarded recipe to be saved individually: %v", err) 156 + } 157 + if stored.Title != discarded.Title { 158 + t.Fatalf("expected discarded recipe title %q, got %q", discarded.Title, stored.Title) 159 + } 160 + if stored.OriginHash != hash { 161 + t.Fatalf("expected discarded recipe origin hash %q, got %q", hash, stored.OriginHash) 162 + } 163 + 164 + cachedList, err := rio.FromCache(t.Context(), hash) 165 + if err != nil { 166 + t.Fatalf("FromCache failed: %v", err) 167 + } 168 + if len(cachedList.Discarded) != 0 { 169 + t.Fatalf("expected cached shopping list to omit discarded recipes, got %+v", cachedList.Discarded) 170 + } 171 + } 172 + 117 173 func TestSaveIngredients_UsesPrefixedKey(t *testing.T) { 118 174 tmpDir := t.TempDir() 119 175 cacheStore := cache.NewFileCache(tmpDir)
+15 -42
internal/recipes/server_test.go
··· 355 355 Instructions: []string{"Roast salmon and vegetables until done."}, 356 356 Health: "High protein", 357 357 DrinkPairing: "Pinot Noir", 358 + OriginHash: legacyHash, 358 359 } 359 360 recipeHash := recipe.ComputeHash() 360 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, legacyHash); err != nil { 361 - t.Fatalf("failed to save recipe with legacy origin hash: %v", err) 362 - } 361 + saveRecipesForOrigin(t, s, legacyHash, recipe) 363 362 364 363 req := httptest.NewRequest(http.MethodGet, "/recipe/"+recipeHash, nil) 365 364 req.SetPathValue("hash", recipeHash) ··· 409 408 DrinkPairing: "Sparkling water", 410 409 } 411 410 recipeHash := recipe.ComputeHash() 412 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, legacyHash); err != nil { 413 - t.Fatalf("failed to save recipe with legacy origin hash: %v", err) 414 - } 411 + saveRecipesForOrigin(t, s, legacyHash, recipe) 415 412 416 413 req := httptest.NewRequest(http.MethodGet, "/recipe/"+recipeHash, nil) 417 414 req.SetPathValue("hash", recipeHash) ··· 448 445 DrinkPairing: "Pinot noir", 449 446 } 450 447 recipeHash := recipe.ComputeHash() 451 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 452 - t.Fatalf("failed to save recipe: %v", err) 453 - } 448 + saveRecipesForOrigin(t, s, originHash, recipe) 454 449 if err := s.SaveWine(t.Context(), recipeHash, &ai.WineSelection{ 455 450 Wines: []ai.Ingredient{ 456 451 {Name: "Light Pinot Noir", Price: "$13.99"}, ··· 845 840 WineStyles: []string{"pinot noir"}, 846 841 } 847 842 recipeHash := recipe.ComputeHash() 848 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 849 - t.Fatalf("failed to save recipe: %v", err) 850 - } 843 + saveRecipesForOrigin(t, s, originHash, recipe) 851 844 852 845 req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/wine", nil) 853 846 req.Header.Set("HX-Request", "true") ··· 899 892 WineStyles: []string{"pinot noir"}, 900 893 } 901 894 recipeHash := recipe.ComputeHash() 902 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 903 - t.Fatalf("failed to save recipe: %v", err) 904 - } 895 + saveRecipesForOrigin(t, s, originHash, recipe) 905 896 906 897 req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/wine?view=shopping", nil) 907 898 req.Header.Set("HX-Request", "true") ··· 950 941 WineStyles: []string{"pinot noir"}, 951 942 } 952 943 recipeHash := recipe.ComputeHash() 953 - if err := s1.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 954 - t.Fatalf("failed to save recipe: %v", err) 955 - } 944 + saveRecipesForOrigin(t, s1, originHash, recipe) 956 945 957 946 req1 := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/wine", nil) 958 947 req1.Header.Set("HX-Request", "true") ··· 1048 1037 Instructions: []string{"Roast until done."}, 1049 1038 } 1050 1039 recipeHash := recipe.ComputeHash() 1051 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "origin-hash"); err != nil { 1052 - t.Fatalf("failed to save recipe: %v", err) 1053 - } 1040 + saveRecipesForOrigin(t, s, "origin-hash", recipe) 1054 1041 1055 1042 req1 := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/image", nil) 1056 1043 req1.Header.Set("HX-Request", "true") ··· 1123 1110 t.Fatalf("failed to save params: %v", err) 1124 1111 } 1125 1112 recipeHash := recipe.ComputeHash() 1126 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 1127 - t.Fatalf("failed to save recipe in cache: %v", err) 1128 - } 1113 + saveRecipesForOrigin(t, s, originHash, recipe) 1129 1114 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1130 1115 Recipes: []ai.Recipe{recipe}, 1131 1116 ConversationID: "conv-123", ··· 1212 1197 t.Fatalf("failed to save params: %v", err) 1213 1198 } 1214 1199 recipeHash := recipe.ComputeHash() 1215 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "stale-origin-hash"); err != nil { 1216 - t.Fatalf("failed to save recipe in cache: %v", err) 1217 - } 1200 + saveRecipesForOrigin(t, s, "stale-origin-hash", recipe) 1218 1201 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1219 1202 Recipes: []ai.Recipe{recipe}, 1220 1203 ConversationID: "conv-123", ··· 1268 1251 t.Fatalf("failed to save params: %v", err) 1269 1252 } 1270 1253 recipeHash := recipe.ComputeHash() 1271 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 1272 - t.Fatalf("failed to save recipe in cache: %v", err) 1273 - } 1254 + saveRecipesForOrigin(t, s, originHash, recipe) 1274 1255 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1275 1256 Recipes: []ai.Recipe{recipe}, 1276 1257 ConversationID: "conv-123", ··· 1379 1360 t.Fatalf("failed to save params: %v", err) 1380 1361 } 1381 1362 recipeHash := recipe.ComputeHash() 1382 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "stale-origin-hash"); err != nil { 1383 - t.Fatalf("failed to save recipe in cache: %v", err) 1384 - } 1363 + saveRecipesForOrigin(t, s, "stale-origin-hash", recipe) 1385 1364 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1386 1365 Recipes: []ai.Recipe{recipe}, 1387 1366 ConversationID: "conv-123", ··· 1450 1429 1451 1430 savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 1452 1431 dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 1453 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{savedRecipe, dismissedRecipe}, originHash); err != nil { 1454 - t.Fatalf("failed to save recipes: %v", err) 1455 - } 1432 + saveRecipesForOrigin(t, s, originHash, savedRecipe, dismissedRecipe) 1456 1433 shoppingList := &ai.ShoppingList{ 1457 1434 Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1458 1435 ConversationID: "conv-123", ··· 1535 1512 t.Fatalf("failed to save params: %v", err) 1536 1513 } 1537 1514 1538 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{alreadySaved, newlySaved, available}, originHash); err != nil { 1539 - t.Fatalf("failed to save recipes: %v", err) 1540 - } 1515 + saveRecipesForOrigin(t, s, originHash, alreadySaved, newlySaved, available) 1541 1516 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1542 1517 Recipes: []ai.Recipe{alreadySaved, newlySaved, available}, 1543 1518 ConversationID: "conv-123", ··· 1599 1574 1600 1575 savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 1601 1576 dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 1602 - if err := s.SaveRecipes(t.Context(), []ai.Recipe{savedRecipe, dismissedRecipe}, originHash); err != nil { 1603 - t.Fatalf("failed to save recipes: %v", err) 1604 - } 1577 + saveRecipesForOrigin(t, s, originHash, savedRecipe, dismissedRecipe) 1605 1578 shoppingList := &ai.ShoppingList{ 1606 1579 Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1607 1580 ConversationID: "conv-123",
+24
internal/recipes/testing_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "careme/internal/ai" 8 + ) 9 + 10 + type recipeSaver interface { 11 + saveRecipes(ctx context.Context, recipes []ai.Recipe) error 12 + } 13 + 14 + func saveRecipesForOrigin(t *testing.T, saver recipeSaver, originHash string, recipes ...ai.Recipe) { 15 + t.Helper() 16 + 17 + for i := range recipes { 18 + recipes[i].OriginHash = originHash 19 + } 20 + 21 + if err := saver.saveRecipes(t.Context(), recipes); err != nil { 22 + t.Fatalf("failed to save recipes: %v", err) 23 + } 24 + }
+2 -2
internal/sitemap/sitemap_test.go
··· 99 99 Health: "Balanced dinner", 100 100 DrinkPairing: "Pinot Noir", 101 101 } 102 - if err := list.SaveRecipes(context.Background(), []ai.Recipe{recipe}, shoppingListHash); err != nil { 103 - t.Fatalf("failed to save recipe: %v", err) 102 + if err := list.SaveShoppingList(context.Background(), &ai.ShoppingList{Recipes: []ai.Recipe{recipe}}, shoppingListHash); err != nil { 103 + t.Fatalf("failed to save shopping list: %v", err) 104 104 } 105 105 recipeHash := recipe.ComputeHash() 106 106 if err := list.SaveFeedback(context.Background(), recipeHash, feedback.Feedback{