ai cooking
0
fork

Configure Feed

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

Save parent recipes for critique linking (#503)

* reduced

* okay lineages gone again

* simplify title matching

* passing tests

* simnplify

* fix and comeback for better html tests

* fumpt

* extra s

* don't depend on ordering

authored by

Paul Miller and committed by
GitHub
fcebff46 21812949

+251 -22
+6 -6
internal/ai/client.go
··· 53 53 DrinkPairing string `json:"drink_pairing"` 54 54 WineStyles []string `json:"wine_styles"` 55 55 OriginHash string `json:"origin_hash,omitempty" jsonschema:"-"` // not in schema 56 + ParentHash string `json:"parent_hash,omitempty" jsonschema:"-"` // regeneration metadata, not in schema 56 57 Saved bool `json:"previously_saved,omitempty" jsonschema:"-"` // not in schema 57 58 } 58 59 59 60 // ComputeHash calculates the fnv128 hash of the recipe content 60 61 func (r *Recipe) ComputeHash() string { 61 - //these are intentionally dropped as they don't change the content and are metadata 62 - // maybe they should have always been outside the struct. 63 - /// OriginHash = "" 64 - // Saved = false 62 + // OriginHash, ParentHash, Saved are intentionally excluded because they describe provenance or UI state, 63 + // not the recipe content itself. If ancestor links ever need to affect identity, that 64 + // is a separate model change and should not happen implicitly here. 65 65 fnv := fnv.New128a() 66 66 lo.Must(io.WriteString(fnv, r.Title)) 67 67 lo.Must(io.WriteString(fnv, r.Description)) ··· 161 161 - Home cooked by a above average cook, not a restaurant or food stylist. 162 162 - Keep plating simple and believable. No tweezers, foam, edible flowers, microgreens, or luxury flourishes unless in recipe instructions. 163 163 - Use a simple kitchen counter, stovetop, sheet pan, wooden table, or casual dining table backdrop. 164 - - Use natural colors, ordinary cookware or tableware, and realistic portions 165 - - Avoid text, labels, branded packaging, people, hands, collages, and extra side dishes 164 + - Use natural colors, ordinary cookware or tableware, and realistic portions 165 + - Avoid text, labels, branded packaging, people, hands, collages, and extra side dishes 166 166 - If the recipe has multiple components, show them plated together 167 167 ` 168 168
+103 -10
internal/recipes/generator.go
··· 10 10 "slices" 11 11 "strings" 12 12 "time" 13 + "unicode" 13 14 14 15 "careme/internal/ai" 15 16 "careme/internal/kroger" ··· 102 103 103 104 if p.ConversationID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) { 104 105 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID) 105 - // should never get a conversation id without instructions or saved/dismissed 106 - // could assert or warn on that 107 - instructions := []string{p.Instructions} 108 - for _, dismissed := range p.Dismissed { 109 - instructions = append(instructions, "Passed on "+dismissed.Title) 110 - } 111 - for _, saved := range newlySaved(p.Saved, p.PriorSavedHashes) { 112 - instructions = append(instructions, "Enjoyed and saved so don't repeat: "+saved) 113 - } 114 - // TODO more guidance on how many recipes to generate? 106 + instructions := regenerateInstructions(p) 115 107 116 108 shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ConversationID) 117 109 if err != nil { ··· 203 195 return b.String() 204 196 } 205 197 198 + func regenerateInstructions(p *generatorParams) []string { 199 + instructions := make([]string, 0, 1+len(p.Dismissed)+len(p.Saved)) 200 + if trimmed := strings.TrimSpace(p.Instructions); trimmed != "" { 201 + instructions = append(instructions, trimmed) 202 + } 203 + for _, dismissed := range p.Dismissed { 204 + instructions = append(instructions, "Passed on "+dismissed.Title) 205 + } 206 + for _, saved := range newlySaved(p.Saved, p.PriorSavedHashes) { 207 + instructions = append(instructions, "Enjoyed and saved so don't repeat: "+saved) 208 + } 209 + return instructions 210 + } 211 + 206 212 func (g *generatorService) critiqueAndMaybeRetry(ctx context.Context, hash string, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 207 213 if g.critiquer == nil { 208 214 return shoppingList, nil ··· 230 236 return nil, fmt.Errorf("failed to regenerate recipes from critique feedback: %w", err) 231 237 } 232 238 newRecipes := shoppingList.Recipes 239 + linkToParents(garbage, recipePtrs(newRecipes)) 233 240 shoppingList.Recipes = append(shoppingList.Recipes, good...) 234 241 shoppingList.Discarded = lo.Map(garbage, func(result critique.Result, _ int) ai.Recipe { 235 242 return *result.Recipe ··· 249 256 slog.ErrorContext(ctx, "failed to save generation status", "hash", hash, "status", status, "error", err) 250 257 } 251 258 } 259 + 260 + func linkToParents(garbage []critique.Result, newRecipes []*ai.Recipe) { 261 + parents := lo.Map(garbage, func(result critique.Result, _ int) *ai.Recipe { 262 + return result.Recipe 263 + }) 264 + applyParentHashesByTitleMatch(parents, newRecipes) 265 + } 266 + 267 + func recipePtrs(recipes []ai.Recipe) []*ai.Recipe { 268 + ptrs := make([]*ai.Recipe, 0, len(recipes)) 269 + for i := range recipes { 270 + ptrs = append(ptrs, &recipes[i]) 271 + } 272 + return ptrs 273 + } 274 + 275 + func applyParentHashesByTitleMatch(parents []*ai.Recipe, newRecipes []*ai.Recipe) { 276 + type candidateMatch struct { 277 + new *ai.Recipe 278 + parent *ai.Recipe 279 + score int 280 + } 281 + 282 + matches := make([]candidateMatch, 0, len(newRecipes)*len(parents)) 283 + for _, newRecipe := range newRecipes { 284 + for _, parent := range parents { 285 + score := sharedWords(newRecipe.Title, parent.Title) 286 + if score == 0 { 287 + continue 288 + } 289 + matches = append(matches, candidateMatch{ 290 + new: newRecipe, 291 + parent: parent, 292 + score: score, 293 + }) 294 + } 295 + } 296 + 297 + slices.SortFunc(matches, func(a, b candidateMatch) int { 298 + return b.score - a.score 299 + }) 300 + 301 + used := make(map[*ai.Recipe]bool, len(newRecipes)) 302 + for _, match := range matches { 303 + if match.new == nil || match.parent == nil { 304 + continue 305 + } 306 + if used[match.new] { 307 + continue 308 + } 309 + if used[match.parent] { 310 + continue 311 + } 312 + parentHash := match.parent.ComputeHash() 313 + childHash := match.new.ComputeHash() 314 + if parentHash == "" || childHash == "" || parentHash == childHash { 315 + continue 316 + } 317 + match.new.ParentHash = parentHash 318 + used[match.new] = true 319 + used[match.parent] = true 320 + } 321 + } 322 + 323 + func sharedWords(a, b string) int { 324 + wordsA := wordSet(a) 325 + wordsB := wordSet(b) 326 + return lo.CountBy(lo.Keys(wordsA), func(word string) bool { 327 + return wordsB[word] 328 + }) 329 + } 330 + 331 + func wordSet(title string) map[string]bool { 332 + wordDict := make(map[string]bool) 333 + s := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool { 334 + return !unicode.IsLetter(r) && !unicode.IsNumber(r) 335 + }) 336 + for _, word := range s { 337 + // tiny words not valuable? 338 + if len(word) < 2 { 339 + continue 340 + } 341 + wordDict[word] = true 342 + } 343 + return wordDict 344 + }
+137 -6
internal/recipes/generator_test.go
··· 695 695 696 696 func TestGenerateRecipes_RegenerateRetriesLowScoringRecipesOnce(t *testing.T) { 697 697 alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 698 - initial := ai.Recipe{Title: "Needs Work", Description: "First pass"} 699 - retried := ai.Recipe{Title: "Ready Now", Description: "Second pass"} 698 + initial := ai.Recipe{Title: "Needs Work Dinner", Description: "First pass"} 699 + retried := ai.Recipe{Title: "Ready Dinner", Description: "Second pass"} 700 700 701 701 aiStub := &sequenceAIClient{ 702 702 regenerateResponses: []*ai.ShoppingList{ ··· 713 713 critiquer := &captureCritiqueService{ 714 714 fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 715 715 switch recipe.Title { 716 - case "Needs Work": 716 + case "Needs Work Dinner": 717 717 return &ai.RecipeCritique{ 718 718 SchemaVersion: "recipe-critique-v1", 719 719 OverallScore: 5, ··· 722 722 Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "timing", Detail: "Cooking times are inconsistent."}}, 723 723 SuggestedFixes: []string{"make the timing consistent"}, 724 724 }, nil 725 - case "Ready Now": 725 + case "Ready Dinner": 726 726 return &ai.RecipeCritique{ 727 727 SchemaVersion: "recipe-critique-v1", 728 728 OverallScore: 8, ··· 755 755 if got == nil || len(got.Recipes) != 2 { 756 756 t.Fatalf("expected regenerated list plus saved recipe, got %+v", got) 757 757 } 758 - if got.Recipes[0].Title != "Ready Now" || got.Recipes[1].Title != "Already Saved" { 758 + if got.Recipes[0].Title != "Ready Dinner" || got.Recipes[1].Title != "Already Saved" { 759 759 t.Fatalf("unexpected recipe order after critique retry: %+v", got.Recipes) 760 + } 761 + if got.Recipes[0].ParentHash != initial.ComputeHash() { 762 + t.Fatalf("expected retried recipe to point to the first-pass recipe, got %+v", got.Recipes[0]) 760 763 } 761 764 if got.ConversationID != "conv-second-pass" { 762 765 t.Fatalf("expected final conversation ID %q, got %q", "conv-second-pass", got.ConversationID) ··· 769 772 } 770 773 wantRetryInstructions := []string{ 771 774 "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", 772 - "Recipe \"Needs Work\" scored 5/10.\n Issues: [timing/medium] Cooking times are inconsistent.\n Suggested fixes: make the timing consistent", 775 + "Recipe \"Needs Work Dinner\" scored 5/10.\n Issues: [timing/medium] Cooking times are inconsistent.\n Suggested fixes: make the timing consistent", 773 776 } 774 777 if got := aiStub.regenerateInstructions[1]; !slices.Equal(got, wantRetryInstructions) { 775 778 t.Fatalf("unexpected critique retry instructions: got %v want %v", got, wantRetryInstructions) 779 + } 780 + } 781 + 782 + func TestGenerateRecipes_CritiqueRetryPointsToImmediateParent(t *testing.T) { 783 + firstPass := ai.Recipe{Title: "First Pass Dinner", Description: "Needs work"} 784 + retried := ai.Recipe{Title: "Second Pass Dinner", Description: "Improved"} 785 + 786 + aiStub := &sequenceAIClient{ 787 + regenerateResponses: []*ai.ShoppingList{ 788 + { 789 + ConversationID: "conv-first-pass", 790 + Recipes: []ai.Recipe{firstPass}, 791 + }, 792 + { 793 + ConversationID: "conv-second-pass", 794 + Recipes: []ai.Recipe{retried}, 795 + }, 796 + }, 797 + } 798 + critiquer := &captureCritiqueService{ 799 + fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 800 + if recipe.Title == "First Pass Dinner" { 801 + return &ai.RecipeCritique{ 802 + SchemaVersion: "recipe-critique-v1", 803 + OverallScore: 6, 804 + Summary: "Needs revision.", 805 + Strengths: []string{"good bones"}, 806 + Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "clarity", Detail: "Need clearer steps."}}, 807 + SuggestedFixes: []string{"clarify the steps"}, 808 + }, nil 809 + } 810 + return &ai.RecipeCritique{ 811 + SchemaVersion: "recipe-critique-v1", 812 + OverallScore: 8, 813 + Summary: "Ready to cook.", 814 + Strengths: []string{"clear direction"}, 815 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "timing", Detail: "Minor timing cleanup."}}, 816 + SuggestedFixes: []string{"tighten the simmer time"}, 817 + }, nil 818 + }, 819 + } 820 + g := &generatorService{ 821 + aiClient: aiStub, 822 + critiquer: critiquer, 823 + statusWriter: noopstatuswriter{}, 824 + } 825 + 826 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 827 + params.ConversationID = "conv-original" 828 + params.Instructions = "make it fresher" 829 + got, err := g.GenerateRecipes(t.Context(), params) 830 + if err != nil { 831 + t.Fatalf("GenerateRecipes returned error: %v", err) 832 + } 833 + if got == nil || len(got.Recipes) != 1 { 834 + t.Fatalf("expected one retried recipe, got %+v", got) 835 + } 836 + if got.Recipes[0].ParentHash != firstPass.ComputeHash() { 837 + t.Fatalf("expected retried recipe parent hash %q, got %q", firstPass.ComputeHash(), got.Recipes[0].ParentHash) 838 + } 839 + } 840 + 841 + func TestGenerateRecipes_CritiqueRetryMatchesParentByTitleWords(t *testing.T) { 842 + firstPassChicken := ai.Recipe{Title: "Lemon Chicken Pasta", Description: "Needs work"} 843 + firstPassTacos := ai.Recipe{Title: "Spicy Bean Tacos", Description: "Needs work"} 844 + retriedTacos := ai.Recipe{Title: "Weeknight Bean Tacos", Description: "Improved"} 845 + retriedChicken := ai.Recipe{Title: "Bright Lemon Chicken Pasta", Description: "Improved"} 846 + 847 + cacheStore := cache.NewFileCache(t.TempDir()) 848 + io := IO(cacheStore) 849 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 850 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 851 + t.Fatalf("failed to seed ingredients cache: %v", err) 852 + } 853 + 854 + aiStub := &sequenceAIClient{ 855 + generateResponses: []*ai.ShoppingList{{ 856 + ConversationID: "conv-initial", 857 + Recipes: []ai.Recipe{firstPassChicken, firstPassTacos}, 858 + }}, 859 + regenerateResponses: []*ai.ShoppingList{{ 860 + ConversationID: "conv-retried", 861 + Recipes: []ai.Recipe{retriedTacos, retriedChicken}, 862 + }}, 863 + } 864 + critiquer := &captureCritiqueService{ 865 + fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 866 + switch recipe.Title { 867 + case "Lemon Chicken Pasta", "Spicy Bean Tacos": 868 + return &ai.RecipeCritique{ 869 + SchemaVersion: "recipe-critique-v1", 870 + OverallScore: 6, 871 + Summary: "Needs revision.", 872 + Strengths: []string{"good idea"}, 873 + Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "clarity", Detail: "Needs cleaner steps."}}, 874 + SuggestedFixes: []string{"clarify the method"}, 875 + }, nil 876 + default: 877 + return &ai.RecipeCritique{ 878 + SchemaVersion: "recipe-critique-v1", 879 + OverallScore: 8, 880 + Summary: "Ready to cook.", 881 + Strengths: []string{"clear direction"}, 882 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "timing", Detail: "Minor cleanup only."}}, 883 + SuggestedFixes: []string{"tighten the timing"}, 884 + }, nil 885 + } 886 + }, 887 + } 888 + g := &generatorService{ 889 + staples: &cachedStaplesService{cache: io}, 890 + aiClient: aiStub, 891 + critiquer: critiquer, 892 + statusWriter: noopstatuswriter{}, 893 + } 894 + 895 + got, err := g.GenerateRecipes(t.Context(), params) 896 + if err != nil { 897 + t.Fatalf("GenerateRecipes returned error: %v", err) 898 + } 899 + if got == nil || len(got.Recipes) != 2 { 900 + t.Fatalf("expected two retried recipes, got %+v", got) 901 + } 902 + if got.Recipes[0].ParentHash != firstPassTacos.ComputeHash() { 903 + t.Fatalf("expected tacos retry to match tacos parent, got %+v", got.Recipes[0]) 904 + } 905 + if got.Recipes[1].ParentHash != firstPassChicken.ComputeHash() { 906 + t.Fatalf("expected chicken retry to match chicken parent, got %+v", got.Recipes[1]) 776 907 } 777 908 } 778 909
+5
internal/templates/recipe.html
··· 222 222 <a href="{{.RecipeCritiqueURL}}" class="hover:text-gray-500">{{.RecipeCritiqueScore}}/10</a> 223 223 </p> 224 224 {{end}} 225 + {{if .Recipe.ParentHash}} 226 + <p class="text-center text-xs text-gray-400"> 227 + <a href="/recipe/{{.Recipe.ParentHash}}" class="hover:text-gray-500">Previous iteration</a> 228 + </p> 229 + {{end}} 225 230 <p class="text-center text-sm text-gray-500">Planned by Careme.</p> 226 231 </section> 227 232 </main>