ai cooking
0
fork

Configure Feed

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

[codex] add feedback recipes to sitemap (#427)

* add feedback recipes to sitemap

* seems better

* unneeded

* fix tests

* fumpt

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
9f7411af ffbd1dd6

+103 -37
+4
internal/recipes/feedback/model.go
··· 38 38 39 39 const recipeFeedbackPrefix = "recipe_feedback/" 40 40 41 + func RecipeFeedbackPrefix() string { 42 + return recipeFeedbackPrefix 43 + } 44 + 41 45 func (fio FeedbackIO) FeedbackFromCache(ctx context.Context, hash string) (*Feedback, error) { 42 46 feedbackBlob, err := fio.c.Get(ctx, recipeFeedbackPrefix+hash) 43 47 if err != nil {
+10 -10
internal/sitemap/sitemap.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 - "strings" 9 8 10 9 "careme/internal/cache" 11 - "careme/internal/recipes" 10 + "careme/internal/recipes/feedback" 12 11 "careme/internal/routing" 13 12 ) 14 13 ··· 51 50 } 52 51 53 52 func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) { 54 - hashes, err := s.cache.List(r.Context(), recipes.ShoppingListCachePrefix, "") 53 + feedbackHashes, err := s.cache.List(r.Context(), feedback.RecipeFeedbackPrefix(), "") 55 54 if err != nil { 56 55 http.Error(w, "failed to load sitemap", http.StatusInternalServerError) 57 - slog.ErrorContext(r.Context(), "failed to read sitemap urls", "error", err) 56 + slog.ErrorContext(r.Context(), "failed to read feedback urls", "error", err) 58 57 return 59 58 } 60 - entries := make([]urlEntry, 0, len(hashes)+1) 59 + 60 + entries := make([]urlEntry, 0, len(feedbackHashes)+1) 61 61 entries = append(entries, urlEntry{Loc: s.publicOrigin + "/about"}) 62 62 63 63 // this is going to get too big. at some point we need a real db to find latest 64 - // or we track new entries and expire a lsit. 65 - for _, key := range hashes { 66 - hash := strings.TrimPrefix(key, recipes.ShoppingListCachePrefix) 67 - entries = append(entries, urlEntry{Loc: s.publicOrigin + "/recipes?h=" + hash}) 64 + for _, hash := range feedbackHashes { 65 + // would be really strange if recipe had feedback but didn't exist. 66 + // exists, err := s.cache.Exists(r.Context(), recipes.SingleRecipeCacheKey(hash)) 67 + entries = append(entries, urlEntry{Loc: s.publicOrigin + "/recipe/" + hash}) 68 68 } 69 - slog.InfoContext(r.Context(), "serving sitemap with recipe urls", "count", len(entries), "blobcount", len(hashes)) 69 + slog.InfoContext(r.Context(), "serving sitemap with recipe urls", "count", len(entries), "feedback_count", len(feedbackHashes)) 70 70 71 71 w.Header().Set("Content-Type", "application/xml; charset=utf-8") 72 72 if _, err := w.Write([]byte(xml.Header)); err != nil {
+89 -27
internal/sitemap/sitemap_test.go
··· 10 10 "testing" 11 11 "time" 12 12 13 + "careme/internal/ai" 13 14 "careme/internal/cache" 14 15 "careme/internal/locations" 15 16 "careme/internal/recipes" 17 + "careme/internal/recipes/feedback" 16 18 ) 17 19 18 20 const testPublicOrigin = "https://example.careme.test" 19 21 20 - func TestHandleSitemapReturnsXMLWithCachedRecipeHashes(t *testing.T) { 22 + func TestHandleSitemapReturnsXMLWithFeedbackRecipeHashes(t *testing.T) { 21 23 t.Chdir(t.TempDir()) 22 24 23 25 cacheStore := cache.NewFileCache(".") 26 + feedbackIO := feedback.NewIO(cacheStore) 24 27 25 28 start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 26 29 hashes := make([]string, 0, 3) 27 30 for i := range 3 { 28 - loc := &locations.Location{ 29 - ID: fmt.Sprintf("7000500%d", i), 30 - Name: "Test Store", 31 - Address: "123 Test St", 32 - } 33 - params := recipes.DefaultParams(loc, start.AddDate(0, 0, i)) 34 - hash := params.Hash() 35 - if err := cacheStore.Put(context.Background(), "shoppinglist/"+hash, `{"mock":"shopping-list"}`, cache.Unconditional()); err != nil { 36 - t.Fatalf("failed to save hash %q to cache: %v", hash, err) 31 + hash := fmt.Sprintf("recipe-hash-%d", i) 32 + if err := feedbackIO.SaveFeedback(context.Background(), hash, feedback.Feedback{ 33 + Cooked: true, 34 + Stars: 5, 35 + UpdatedAt: start.AddDate(0, 0, i), 36 + }); err != nil { 37 + t.Fatalf("failed to save feedback %q to cache: %v", hash, err) 37 38 } 38 39 hashes = append(hashes, hash) 39 40 } ··· 65 66 } 66 67 67 68 for _, hash := range hashes { 68 - wantURL := testPublicOrigin + "/recipes?h=" + hash 69 + wantURL := testPublicOrigin + "/recipe/" + hash 69 70 if !containsSitemapURL(parsed.URLs, wantURL) { 70 71 t.Fatalf("missing expected URL %q in sitemap body: %s", wantURL, rr.Body.String()) 71 72 } 72 73 } 73 74 } 74 75 75 - func TestHandleSitemapNormalizesLegacyShoppingListHashToCanonical(t *testing.T) { 76 + func TestHandleSitemapIncludesRecipePagesWithFeedback(t *testing.T) { 76 77 t.Chdir(t.TempDir()) 77 78 78 79 cacheStore := cache.NewFileCache(".") 79 - params := recipes.DefaultParams(&locations.Location{ID: "70006001", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 80 - hash := params.Hash() 80 + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 81 + params := recipes.DefaultParams(&locations.Location{ 82 + ID: "70005003", 83 + Name: "Test Store", 84 + Address: "123 Test St", 85 + }, start) 86 + shoppingListHash := params.Hash() 87 + if err := cacheStore.Put(context.Background(), recipes.ShoppingListCachePrefix+shoppingListHash, `{"mock":"shopping-list"}`, cache.Unconditional()); err != nil { 88 + t.Fatalf("failed to save shopping list: %v", err) 89 + } 81 90 82 - if err := cacheStore.Put(context.Background(), "shoppinglist/"+hash, `{"mock":"legacy"}`, cache.Unconditional()); err != nil { 83 - t.Fatalf("failed to save prefixed key: %v", err) 91 + list := recipes.IO(cacheStore) 92 + recipe := ai.Recipe{ 93 + Title: "Feedback Soup", 94 + Description: "A soup worth commenting on.", 95 + CookTime: "35 minutes", 96 + CostEstimate: "$18-24", 97 + Ingredients: []ai.Ingredient{{Name: "Broth", Quantity: "4 cups", Price: "$4"}}, 98 + Instructions: []string{"Bring broth to a simmer.", "Serve hot."}, 99 + Health: "Balanced dinner", 100 + DrinkPairing: "Pinot Noir", 101 + } 102 + if err := list.SaveRecipes(context.Background(), []ai.Recipe{recipe}, shoppingListHash); err != nil { 103 + t.Fatalf("failed to save recipe: %v", err) 104 + } 105 + recipeHash := recipe.ComputeHash() 106 + if err := list.SaveFeedback(context.Background(), recipeHash, feedback.Feedback{ 107 + Cooked: true, 108 + Stars: 5, 109 + Comment: "Worth making again.", 110 + UpdatedAt: start, 111 + }); err != nil { 112 + t.Fatalf("failed to save feedback: %v", err) 84 113 } 85 114 86 115 server := New(cacheStore, testPublicOrigin) ··· 96 125 if err := xml.Unmarshal(rr.Body.Bytes(), &parsed); err != nil { 97 126 t.Fatalf("expected valid XML sitemap, got error: %v\nbody: %s", err, rr.Body.String()) 98 127 } 128 + 99 129 if len(parsed.URLs) != 2 { 100 - t.Fatalf("expected two URLs (about + recipe), got %d", len(parsed.URLs)) 130 + t.Fatalf("expected two URLs (about + feedback-backed recipe), got %d", len(parsed.URLs)) 131 + } 132 + if !containsSitemapURL(parsed.URLs, testPublicOrigin+"/recipe/"+recipeHash) { 133 + t.Fatalf("missing feedback-backed recipe URL in sitemap body: %s", rr.Body.String()) 134 + } 135 + } 136 + 137 + func TestHandleSitemapIncludesFeedbackWithoutCachedRecipe(t *testing.T) { 138 + t.Chdir(t.TempDir()) 139 + 140 + cacheStore := cache.NewFileCache(".") 141 + feedbackIO := feedback.NewIO(cacheStore) 142 + if err := feedbackIO.SaveFeedback(context.Background(), "missing-recipe", feedback.Feedback{ 143 + Cooked: true, 144 + UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 145 + }); err != nil { 146 + t.Fatalf("failed to save feedback: %v", err) 147 + } 148 + 149 + server := New(cacheStore, testPublicOrigin) 150 + rr := httptest.NewRecorder() 151 + req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil) 152 + server.handleSitemap(rr, req) 153 + 154 + if rr.Code != http.StatusOK { 155 + t.Fatalf("expected status 200, got %d", rr.Code) 156 + } 157 + 158 + var parsed urlSet 159 + if err := xml.Unmarshal(rr.Body.Bytes(), &parsed); err != nil { 160 + t.Fatalf("expected valid XML sitemap, got error: %v\nbody: %s", err, rr.Body.String()) 161 + } 162 + if len(parsed.URLs) != 2 { 163 + t.Fatalf("expected two URLs (about + feedback-backed recipe), got %d", len(parsed.URLs)) 101 164 } 102 165 if !containsSitemapURL(parsed.URLs, testPublicOrigin+"/about") { 103 166 t.Fatalf("missing expected static URL %q in sitemap body: %s", testPublicOrigin+"/about", rr.Body.String()) 104 167 } 105 - wantURL := testPublicOrigin + "/recipes?h=" + hash 106 - if !containsSitemapURL(parsed.URLs, wantURL) { 107 - t.Fatalf("missing expected URL %q in sitemap body: %s", wantURL, rr.Body.String()) 168 + if !containsSitemapURL(parsed.URLs, testPublicOrigin+"/recipe/missing-recipe") { 169 + t.Fatalf("missing expected URL %q in sitemap body: %s", testPublicOrigin+"/recipe/missing-recipe", rr.Body.String()) 108 170 } 109 171 } 110 172 111 - func TestHandleSitemap_IgnoresNonShoppingListKeys(t *testing.T) { 173 + func TestHandleSitemap_IgnoresNonFeedbackKeys(t *testing.T) { 112 174 t.Chdir(t.TempDir()) 113 175 114 176 cacheStore := cache.NewFileCache(".") 115 - params := recipes.DefaultParams(&locations.Location{ID: "70006002", Name: "Store"}, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) 116 - hash := params.Hash() 117 - 118 - if err := cacheStore.Put(context.Background(), hash, `{"mock":"legacy-root-shopping-list"}`, cache.Unconditional()); err != nil { 119 - t.Fatalf("failed to save legacy root key: %v", err) 177 + if err := cacheStore.Put(context.Background(), recipes.ShoppingListCachePrefix+"ignored-shopping-list", `{"mock":"shopping-list"}`, cache.Unconditional()); err != nil { 178 + t.Fatalf("failed to save shopping list key: %v", err) 179 + } 180 + if err := cacheStore.Put(context.Background(), "recipe/ignored-recipe", `{"mock":"recipe"}`, cache.Unconditional()); err != nil { 181 + t.Fatalf("failed to save recipe key: %v", err) 120 182 } 121 183 122 184 server := New(cacheStore, testPublicOrigin) ··· 133 195 t.Fatalf("expected valid XML sitemap, got error: %v\nbody: %s", err, rr.Body.String()) 134 196 } 135 197 if len(parsed.URLs) != 1 { 136 - t.Fatalf("expected one URL (about) with no shoppinglist keys, got %d", len(parsed.URLs)) 198 + t.Fatalf("expected one URL (about) with no feedback keys, got %d", len(parsed.URLs)) 137 199 } 138 200 if parsed.URLs[0].Loc != testPublicOrigin+"/about" { 139 201 t.Fatalf("expected only URL %q, got %q", testPublicOrigin+"/about", parsed.URLs[0].Loc)