ai cooking
0
fork

Configure Feed

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

Recipestodo (#491)

* looking better

* little cleaner web.go

* admin moved over

* missed add

* no exports

* improve tests

* unncessary

* got rid of more filth

* ugliness begon

* just take a list cache

* internal

* comments and unncessary

* go back

* format

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
21539f4a 52ef392e

+795 -532
+34 -18
cmd/careme/web.go
··· 14 14 15 15 "careme/internal/actowiz" 16 16 "careme/internal/admin" 17 + "careme/internal/ai" 17 18 "careme/internal/auth" 18 19 "careme/internal/config" 19 20 "careme/internal/ingredients" 20 21 "careme/internal/locations" 21 22 "careme/internal/recipes" 23 + "careme/internal/recipes/critique" 22 24 "careme/internal/routing" 23 25 "careme/internal/seasons" 24 26 "careme/internal/sitemap" ··· 57 59 static.Register(infraRoutes) 58 60 59 61 userStorage := users.NewStorage(cache) 62 + ro := &readyOnce{} 63 + watchdogServer := watchdog.Server{} 60 64 61 - mc := recipes.NewMultiCritiquer(cfg, cache) 62 - generator, err := recipes.NewGenerator(cfg, cache, mc) 63 - if err != nil { 64 - return fmt.Errorf("failed to create recipe generator: %w", err) 65 + var generator recipes.ExtGenerator 66 + var waitFns []func() 67 + 68 + if cfg.Mocks.Enable { 69 + generator = recipes.NewMockGenerator() 70 + } else { 71 + mc := critique.NewManager(cfg, cache) 72 + ro.Add(mc) 73 + aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 74 + ro.Add(aiclient) 75 + staples, err := recipes.NewCachedStaplesService(cfg, cache) 76 + if err != nil { 77 + return fmt.Errorf("failed to create staples service: %w", err) 78 + } 79 + watchdogServer.Add("staples", staples, 6.*time.Hour) 80 + generator, err = recipes.NewGenerator(aiclient, mc, staples) 81 + if err != nil { 82 + return fmt.Errorf("failed to create recipe generator: %w", err) 83 + } 84 + waitFns = append(waitFns, mc.Wait) 65 85 } 86 + watchdogServer.Register(infraRoutes) 66 87 67 88 centroids := locations.LoadCentroids() 68 89 ··· 75 96 userHandler.Register(appRoutes) 76 97 77 98 locationServer := locations.NewServer(locationStorage, centroids, userStorage) 99 + ro.Add(locationServer) 78 100 locationServer.Register(appRoutes, authClient) 79 101 80 102 sitemapHandler := sitemap.New(cache, cfg.ResolvedPublicOrigin()) ··· 82 104 83 105 recipeHandler := recipes.NewHandler(cfg, userStorage, generator, locationStorage, cache, imageCache, authClient) 84 106 recipeHandler.Register(appRoutes) 107 + waitFns = append([]func(){recipeHandler.Wait}, waitFns...) 85 108 86 109 actowiz.NewServer(locationStorage).Register(infraRoutes) 87 110 88 - watchdogServer := watchdog.Server{} 89 - watchdogServer.Add("staples", generator, 6.*time.Hour) 90 - watchdogServer.Register(infraRoutes) 91 - 92 111 adminMux := http.NewServeMux() 93 112 adminMux.Handle("/users", users.AdminUsersPage(userStorage)) 94 - adminMux.Handle("/critiques", recipes.AdminCritiquesPage(cache)) 113 + recipeIO := recipes.IO(cache) 114 + adminMux.Handle("/critiques", critique.AdminCritiquesPage(critique.NewStore(cache), recipeIO)) 95 115 ingredientsHandler := ingredients.NewHandler(cache) 96 116 ingredientsHandler.Register(adminMux) 97 117 appRoutes.Handle("/admin/", admin.New(cfg, authClient).Enforce(http.StripPrefix("/admin", adminMux))) ··· 150 170 } 151 171 }) 152 172 153 - ro := &readyOnce{} 154 - ro.Add(generator, locationServer, mc) 155 - 156 173 // no logging for readyiness too noisy. 157 174 rootMux.Handle("/ready", &recoverer{ro}) 158 175 ··· 183 200 return nil 184 201 case sig := <-shutdown: 185 202 slog.Info("Shutdown signal received", "signal", sig) 186 - return gracefulShutdown(server, func() { 187 - recipeHandler.Wait() 188 - mc.Wait() 189 - }) 203 + return gracefulShutdown(server, waitFns...) 190 204 } 191 205 } 192 206 193 - func gracefulShutdown(svr *http.Server, wait func()) error { 207 + func gracefulShutdown(svr *http.Server, waitFns ...func()) error { 194 208 // Give outstanding requests 25 seconds to complete (kubernetes has 30 second grace period) 195 209 time.Sleep(5 * time.Second) // buffer to allow ingress ot update. only needed in prod 196 210 ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) ··· 208 222 209 223 done := make(chan struct{}) 210 224 go func() { 211 - wait() 225 + for _, wait := range waitFns { 226 + wait() 227 + } 212 228 close(done) 213 229 }() 214 230
+2 -6
cmd/careme/web_e2e_test.go
··· 162 162 cacheDir := filepath.Join(t.TempDir(), "cache") 163 163 cacheStore := cache.NewFileCache(cacheDir) 164 164 userStorage := users.NewStorage(cacheStore) 165 - 166 - generator, err := recipes.NewGenerator(cfg, cacheStore, recipes.NewMultiCritiquer(cfg, cacheStore)) 167 - if err != nil { 168 - t.Fatalf("failed to create generator: %v", err) 169 - } 165 + generator := recipes.NewMockGenerator() 170 166 centroids := locations.LoadCentroids() 171 167 locationStorage, err := locations.New(cfg, cacheStore, centroids) 172 168 if err != nil { ··· 186 182 recipes.NewHandler(cfg, userStorage, generator, locationStorage, cacheStore, cacheStore, mockAuth).Register(appRoutes) 187 183 188 184 ro := &readyOnce{} 189 - ro.Add(generator, locationServer) 185 + ro.Add(locationServer) 190 186 191 187 infraRoutes.Handle("/ready", ro) 192 188
+7 -2
internal/mail/mail.go
··· 19 19 "careme/internal/config" 20 20 "careme/internal/locations" 21 21 "careme/internal/recipes" 22 + "careme/internal/recipes/critique" 22 23 "careme/internal/users" 23 24 24 25 utypes "careme/internal/users/types" ··· 67 68 } 68 69 69 70 userStorage := users.NewStorage(cache) 70 - mc := recipes.NewMultiCritiquer(cfg, cache) 71 - generator, err := recipes.NewGenerator(cfg, cache, mc) 71 + mc := critique.NewManager(cfg, cache) 72 + staples, err := recipes.NewCachedStaplesService(cfg, cache) 73 + if err != nil { 74 + return nil, fmt.Errorf("failed to create staples service: %w", err) 75 + } 76 + generator, err := recipes.NewGenerator(ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), mc, staples) 72 77 if err != nil { 73 78 return nil, fmt.Errorf("failed to create recipe generator: %w", err) 74 79 }
+23 -14
internal/recipes/admin_page.go internal/recipes/critique/admin_page.go
··· 1 - package recipes 1 + package critique 2 2 3 3 import ( 4 4 "context" ··· 8 8 "sort" 9 9 10 10 "careme/internal/ai" 11 - "careme/internal/cache" 12 11 "careme/internal/parallelism" 13 12 14 13 "github.com/samber/lo" ··· 47 46 {{range .Critiques}} 48 47 <tr> 49 48 <td> 50 - <a href="{{.RecipeURL}}">{{.RecipeTitle}}</a> 49 + <a href="{{.RecipeURL}}">{{.RecipeTitle}}</a> 51 50 </td> 52 51 <td>{{.OverallScore}}/10</td> 53 52 <td> ··· 89 88 </body> 90 89 </html>`)) 91 90 92 - func AdminCritiquesPage(c cache.ListCache) http.Handler { 91 + type recipeio interface { 92 + SingleFromCache(ctx context.Context, hash string) (*ai.Recipe, error) 93 + } 94 + 95 + func AdminCritiquesPage(s store, rio recipeio) http.Handler { 96 + if rio == nil { 97 + panic("store and recipeio must not be nil") 98 + } 99 + 93 100 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 101 if r.Method != http.MethodGet && r.Method != http.MethodHead { 95 102 w.WriteHeader(http.StatusMethodNotAllowed) 96 103 return 97 104 } 98 105 99 - // this won't last long till its too big. 100 - hashes, err := c.List(r.Context(), recipeCritiquesCachePrefix, "") 106 + hashes, err := s.ListHashes(r.Context()) 101 107 if err != nil { 102 108 slog.ErrorContext(r.Context(), "failed to list recipe critiques for admin page", "error", err) 103 109 http.Error(w, "unable to load recipe critiques", http.StatusInternalServerError) 104 110 return 105 111 } 106 112 107 - views, err := loadAdminCritiqueViews(r.Context(), c, hashes) 113 + views, err := loadAdminCritiqueViews(r.Context(), s, rio, hashes) 108 114 if err != nil { 109 115 slog.ErrorContext(r.Context(), "failed to load recipe critiques for admin page", "error", err) 110 116 http.Error(w, "unable to load recipe critiques", http.StatusInternalServerError) ··· 127 133 }) 128 134 } 129 135 130 - func loadAdminCritiqueViews(ctx context.Context, c cache.Cache, hashes []string) ([]*adminCritiqueView, error) { 131 - rio := IO(c) 136 + func loadAdminCritiqueViews( 137 + ctx context.Context, 138 + store store, 139 + rio recipeio, 140 + hashes []string, 141 + ) ([]*adminCritiqueView, error) { 132 142 views, err := parallelism.MapWithErrors(hashes, func(hash string) (*adminCritiqueView, error) { 133 143 view := adminCritiqueView{ 134 144 RecipeURL: "/recipe/" + hash, 135 145 } 136 146 137 - critique, err := rio.CritiqueFromCache(ctx, hash) 147 + cachedCritique, err := store.Load(ctx, hash) 138 148 if err != nil { 139 149 return nil, err 140 150 } 141 - view.RecipeCritique = *critique 151 + view.RecipeCritique = *cachedCritique 142 152 143 - recipe, err := rio.SingleFromCache(ctx, hash) 153 + recipeTitle, err := rio.SingleFromCache(ctx, hash) 144 154 if err != nil { 145 - // make this an error after we don't load all critiques or purge ones where we didn't discart 146 155 slog.InfoContext(ctx, "failed to load recipe for admin critiques page", "hash", hash, "error", err) 147 156 view.RecipeTitle = "Unknown recipe" 148 157 } else { 149 - view.RecipeTitle = recipe.Title 158 + view.RecipeTitle = recipeTitle.Title 150 159 } 151 160 152 161 return &view, nil
+18 -10
internal/recipes/admin_page_test.go internal/recipes/critique/admin_page_test.go
··· 1 - package recipes 1 + package critique_test 2 2 3 3 import ( 4 4 "net/http" ··· 9 9 10 10 "careme/internal/ai" 11 11 "careme/internal/cache" 12 + "careme/internal/recipes" 13 + "careme/internal/recipes/critique" 12 14 ) 13 15 14 16 func TestAdminCritiquesPageRendersNewestFirst(t *testing.T) { 15 17 t.Parallel() 16 18 17 19 fc := cache.NewFileCache(t.TempDir()) 18 - rio := IO(fc) 20 + recipesCache := recipes.IO(fc) 21 + store := critique.NewStore(fc) 19 22 20 - recipes := []ai.Recipe{ 23 + recipeList := []ai.Recipe{ 21 24 { 22 25 Title: "Spring Chicken", 23 26 Description: "Bright and quick.", ··· 43 46 DrinkPairing: "Pinot Grigio", 44 47 }, 45 48 } 46 - saveRecipesForOrigin(t, rio, "origin-hash", recipes...) 49 + if err := recipesCache.SaveShoppingList(t.Context(), &ai.ShoppingList{Recipes: recipeList}, "origin-hash"); err != nil { 50 + t.Fatalf("save shopping list: %v", err) 51 + } 47 52 48 - newestHash := recipes[0].ComputeHash() 49 - olderHash := recipes[1].ComputeHash() 53 + newestHash := recipeList[0].ComputeHash() 54 + olderHash := recipeList[1].ComputeHash() 50 55 51 - if err := rio.SaveCritique(t.Context(), newestHash, &ai.RecipeCritique{ 56 + if err := store.Save(t.Context(), newestHash, &ai.RecipeCritique{ 52 57 SchemaVersion: "recipe-critique-v1", 53 58 OverallScore: 9, 54 59 Summary: "Strong weeknight draft.", ··· 60 65 }); err != nil { 61 66 t.Fatalf("save newest critique: %v", err) 62 67 } 63 - if err := rio.SaveCritique(t.Context(), olderHash, &ai.RecipeCritique{ 68 + if err := store.Save(t.Context(), olderHash, &ai.RecipeCritique{ 64 69 SchemaVersion: "recipe-critique-v1", 65 70 OverallScore: 6, 66 71 Summary: "Needs more brightness.", ··· 76 81 req := httptest.NewRequest(http.MethodGet, "/critiques", nil) 77 82 rr := httptest.NewRecorder() 78 83 79 - AdminCritiquesPage(fc).ServeHTTP(rr, req) 84 + handler := critique.AdminCritiquesPage(store, recipesCache) 85 + handler.ServeHTTP(rr, req) 80 86 81 87 if rr.Code != http.StatusOK { 82 88 t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) ··· 105 111 t.Parallel() 106 112 107 113 fc := cache.NewFileCache(t.TempDir()) 114 + store := critique.NewStore(fc) 115 + recipesCache := recipes.IO(fc) 108 116 109 117 req := httptest.NewRequest(http.MethodPost, "/critiques", nil) 110 118 rr := httptest.NewRecorder() 111 119 112 - AdminCritiquesPage(fc).ServeHTTP(rr, req) 120 + critique.AdminCritiquesPage(store, recipesCache).ServeHTTP(rr, req) 113 121 114 122 if rr.Code != http.StatusMethodNotAllowed { 115 123 t.Fatalf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed)
-65
internal/recipes/caching_critiquer.go
··· 1 - package recipes 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "log/slog" 8 - 9 - "careme/internal/ai" 10 - "careme/internal/cache" 11 - ) 12 - 13 - type recipeCritiquer interface { 14 - CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) 15 - Ready(ctx context.Context) error 16 - } 17 - 18 - // TODO move critique.go over here and get rid of this iterface chacing admin_page 19 - type critiqueCache interface { 20 - CritiqueFromCache(ctx context.Context, hash string) (*ai.RecipeCritique, error) 21 - SaveCritique(ctx context.Context, hash string, critique *ai.RecipeCritique) error 22 - } 23 - 24 - var _ recipeCritiquer = &cachingCritiquer{} 25 - 26 - type cachingCritiquer struct { 27 - critiquer recipeCritiquer 28 - cache critiqueCache 29 - } 30 - 31 - func newCachingCritiquer(critiquer recipeCritiquer, cache cache.Cache) *cachingCritiquer { 32 - if critiquer == nil || cache == nil { 33 - panic("critiquer and cache must not be nil") 34 - } 35 - return &cachingCritiquer{ 36 - critiquer: critiquer, 37 - cache: IO(cache), 38 - } 39 - } 40 - 41 - func (c *cachingCritiquer) Ready(ctx context.Context) error { 42 - return c.critiquer.Ready(ctx) 43 - } 44 - 45 - func (c *cachingCritiquer) CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) { 46 - hash := recipe.ComputeHash() 47 - critique, err := c.cache.CritiqueFromCache(ctx, hash) 48 - if err == nil { 49 - return critique, nil 50 - } 51 - if !errors.Is(err, cache.ErrNotFound) { 52 - slog.ErrorContext(ctx, "failed to load cached recipe critique", "recipe", recipe.Title, "hash", hash, "error", err) 53 - return nil, fmt.Errorf("load cached critique for recipe %q (%s): %w", recipe.Title, hash, err) 54 - } 55 - 56 - critique, err = c.critiquer.CritiqueRecipe(ctx, recipe) 57 - if err != nil { 58 - return nil, err 59 - } 60 - if err := c.cache.SaveCritique(ctx, hash, critique); err != nil { 61 - slog.ErrorContext(ctx, "failed to cache recipe critique", "recipe", recipe.Title, "hash", hash, "error", err) 62 - // not actually fatal 63 - } 64 - return critique, nil 65 - }
-40
internal/recipes/critique.go
··· 1 - package recipes 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - 8 - "careme/internal/ai" 9 - "careme/internal/cache" 10 - ) 11 - 12 - const recipeCritiquesCachePrefix = "recipe_critiques/" 13 - 14 - func recipeCritiqueCacheKey(hash string) string { 15 - return recipeCritiquesCachePrefix + hash 16 - } 17 - 18 - func (rio recipeio) CritiqueFromCache(ctx context.Context, hash string) (*ai.RecipeCritique, error) { 19 - critiqueReader, err := rio.Cache.Get(ctx, recipeCritiqueCacheKey(hash)) 20 - if err != nil { 21 - return nil, err 22 - } 23 - defer func() { 24 - _ = critiqueReader.Close() 25 - }() 26 - var critique ai.RecipeCritique 27 - err = json.NewDecoder(critiqueReader).Decode(&critique) 28 - return &critique, err 29 - } 30 - 31 - func (rio recipeio) SaveCritique(ctx context.Context, hash string, critique *ai.RecipeCritique) error { 32 - if critique == nil { 33 - return fmt.Errorf("recipe critique is required") 34 - } 35 - body, err := json.Marshal(critique) 36 - if err != nil { 37 - return err 38 - } 39 - return rio.Cache.Put(ctx, recipeCritiqueCacheKey(hash), string(body), cache.Unconditional()) 40 - }
+53
internal/recipes/critique/cache.go
··· 1 + package critique 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + 9 + "careme/internal/ai" 10 + "careme/internal/cache" 11 + ) 12 + 13 + var _ recipeCritiquer = &cachingCritiquer{} 14 + 15 + type cachingCritiquer struct { 16 + critiquer recipeCritiquer 17 + store store 18 + } 19 + 20 + func newCachingCritiquer(critiquer recipeCritiquer, store store) *cachingCritiquer { 21 + if critiquer == nil { 22 + panic("critiquer must not be nil") 23 + } 24 + return &cachingCritiquer{ 25 + critiquer: critiquer, 26 + store: store, 27 + } 28 + } 29 + 30 + func (c *cachingCritiquer) Ready(ctx context.Context) error { 31 + return c.critiquer.Ready(ctx) 32 + } 33 + 34 + func (c *cachingCritiquer) CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) { 35 + hash := recipe.ComputeHash() 36 + critique, err := c.store.Load(ctx, hash) 37 + if err == nil { 38 + return critique, nil 39 + } 40 + if !errors.Is(err, cache.ErrNotFound) { 41 + slog.ErrorContext(ctx, "failed to load cached recipe critique", "recipe", recipe.Title, "hash", hash, "error", err) 42 + return nil, fmt.Errorf("load cached critique for recipe %q (%s): %w", recipe.Title, hash, err) 43 + } 44 + 45 + critique, err = c.critiquer.CritiqueRecipe(ctx, recipe) 46 + if err != nil { 47 + return nil, err 48 + } 49 + if err := c.store.Save(ctx, hash, critique); err != nil { 50 + slog.ErrorContext(ctx, "failed to cache recipe critique", "recipe", recipe.Title, "hash", hash, "error", err) 51 + } 52 + return critique, nil 53 + }
+70
internal/recipes/critique/caching_test.go
··· 1 + package critique 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + ) 11 + 12 + type stubCritiquer struct { 13 + readyErr error 14 + calls int 15 + critique *ai.RecipeCritique 16 + err error 17 + } 18 + 19 + func (s *stubCritiquer) Ready(context.Context) error { 20 + return s.readyErr 21 + } 22 + 23 + func (s *stubCritiquer) CritiqueRecipe(_ context.Context, _ ai.Recipe) (*ai.RecipeCritique, error) { 24 + s.calls++ 25 + if s.err != nil { 26 + return nil, s.err 27 + } 28 + return s.critique, nil 29 + } 30 + 31 + func TestCachingCritiquerUsesCacheOnSecondCall(t *testing.T) { 32 + t.Parallel() 33 + 34 + base := &stubCritiquer{ 35 + critique: &ai.RecipeCritique{ 36 + SchemaVersion: "recipe-critique-v1", 37 + OverallScore: 9, 38 + Summary: "Great.", 39 + }, 40 + } 41 + critiquer := newCachingCritiquer(base, NewStore(cache.NewFileCache(t.TempDir()))) 42 + recipe := ai.Recipe{Title: "Roast Chicken"} 43 + 44 + first, err := critiquer.CritiqueRecipe(t.Context(), recipe) 45 + if err != nil { 46 + t.Fatalf("first CritiqueRecipe failed: %v", err) 47 + } 48 + second, err := critiquer.CritiqueRecipe(t.Context(), recipe) 49 + if err != nil { 50 + t.Fatalf("second CritiqueRecipe failed: %v", err) 51 + } 52 + 53 + if base.calls != 1 { 54 + t.Fatalf("calls = %d, want 1", base.calls) 55 + } 56 + if first.Summary != second.Summary { 57 + t.Fatalf("summary mismatch: first=%q second=%q", first.Summary, second.Summary) 58 + } 59 + } 60 + 61 + func TestCachingCritiquerReadyDelegates(t *testing.T) { 62 + t.Parallel() 63 + 64 + want := errors.New("not ready") 65 + critiquer := newCachingCritiquer(&stubCritiquer{readyErr: want}, NewStore(cache.NewFileCache(t.TempDir()))) 66 + 67 + if err := critiquer.Ready(t.Context()); !errors.Is(err, want) { 68 + t.Fatalf("Ready error = %v, want %v", err, want) 69 + } 70 + }
+160
internal/recipes/critique/manager.go
··· 1 + package critique 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "strings" 8 + "sync" 9 + 10 + "careme/internal/ai" 11 + "careme/internal/cache" 12 + "careme/internal/config" 13 + ) 14 + 15 + const MinimumRecipeScore = 8 16 + 17 + type Result struct { 18 + Recipe *ai.Recipe 19 + Critique *ai.RecipeCritique 20 + Err error 21 + } 22 + 23 + type Service interface { 24 + CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan Result 25 + } 26 + 27 + // if we have web.go make rubbertamp directly this goes away 28 + type Manager interface { 29 + Service 30 + Wait() 31 + Ready(ctx context.Context) error 32 + } 33 + 34 + type recipeCritiquer interface { 35 + CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) 36 + Ready(ctx context.Context) error 37 + } 38 + 39 + type rubberstamp struct{} 40 + 41 + func (r rubberstamp) CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan Result { 42 + results := make(chan Result, len(recipes)) 43 + for _, recipe := range recipes { 44 + results <- Result{ 45 + Critique: &ai.RecipeCritique{OverallScore: 10}, 46 + Recipe: &recipe, 47 + } 48 + } 49 + close(results) 50 + return results 51 + } 52 + 53 + func (r rubberstamp) Wait() {} 54 + func (r rubberstamp) Ready(ctx context.Context) error { return nil } 55 + 56 + type multiCritiquer struct { 57 + critiquer recipeCritiquer 58 + wg sync.WaitGroup 59 + } 60 + 61 + func NewManager(cfg *config.Config, c cache.ListCache) Manager { 62 + if !cfg.Gemini.IsEnabled() { 63 + return rubberstamp{} 64 + } 65 + crit := ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel) 66 + return &multiCritiquer{ 67 + critiquer: newCachingCritiquer(crit, NewStore(c)), 68 + } 69 + } 70 + 71 + func (mc *multiCritiquer) Ready(ctx context.Context) error { 72 + return mc.critiquer.Ready(ctx) 73 + } 74 + 75 + func (mc *multiCritiquer) CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan Result { 76 + results := make(chan Result, len(recipes)) 77 + mc.wg.Add(len(recipes)) 78 + 79 + var localWg sync.WaitGroup 80 + for _, recipe := range recipes { 81 + localWg.Go(func() { 82 + defer mc.wg.Done() 83 + critique, err := mc.critiquer.CritiqueRecipe(ctx, recipe) 84 + results <- Result{ 85 + Recipe: &recipe, 86 + Critique: critique, 87 + Err: err, 88 + } 89 + }) 90 + } 91 + go func() { 92 + localWg.Wait() 93 + close(results) 94 + }() 95 + return results 96 + } 97 + 98 + func (mc *multiCritiquer) Wait() { 99 + mc.wg.Wait() 100 + } 101 + 102 + func RetryInstructions(results []Result) []string { 103 + 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)) 104 + instructions := []string{revise} 105 + for _, result := range results { 106 + instructions = append(instructions, fmt.Sprintf( 107 + "Recipe %q scored %d/10.\n Issues: %s\n Suggested fixes: %s", 108 + result.Recipe.Title, 109 + result.Critique.OverallScore, 110 + formatIssues(result.Critique.Issues), 111 + formatSuggestedFixes(result.Critique.SuggestedFixes), 112 + )) 113 + } 114 + return instructions 115 + } 116 + 117 + func Split(ctx context.Context, results <-chan Result, minimumScore int) (accepted []ai.Recipe, retry []Result) { 118 + for result := range results { 119 + if result.Err != nil { 120 + slog.ErrorContext(ctx, "failed to critique recipe", "hash", result.Recipe.ComputeHash(), "title", result.Recipe.Title, "error", result.Err) 121 + accepted = append(accepted, *result.Recipe) 122 + continue 123 + } 124 + 125 + if result.Critique.OverallScore >= minimumScore { 126 + accepted = append(accepted, *result.Recipe) 127 + continue 128 + } 129 + 130 + retry = append(retry, result) 131 + } 132 + return accepted, retry 133 + } 134 + 135 + func formatIssues(issues []ai.RecipeCritiqueIssue) string { 136 + if len(issues) == 0 { 137 + return "none listed." 138 + } 139 + parts := make([]string, 0, len(issues)) 140 + for _, issue := range issues { 141 + parts = append(parts, fmt.Sprintf("[%s/%s] %s", issue.Category, issue.Severity, strings.TrimSpace(issue.Detail))) 142 + } 143 + return strings.Join(parts, "; ") 144 + } 145 + 146 + func formatSuggestedFixes(fixes []string) string { 147 + if len(fixes) == 0 { 148 + return "none listed." 149 + } 150 + trimmed := make([]string, 0, len(fixes)) 151 + for _, fix := range fixes { 152 + if fix = strings.TrimSpace(fix); fix != "" { 153 + trimmed = append(trimmed, fix) 154 + } 155 + } 156 + if len(trimmed) == 0 { 157 + return "none listed." 158 + } 159 + return strings.Join(trimmed, "; ") 160 + }
+59
internal/recipes/critique/multi_test.go
··· 1 + package critique 2 + 3 + import ( 4 + "testing" 5 + 6 + "careme/internal/ai" 7 + "careme/internal/cache" 8 + "careme/internal/config" 9 + ) 10 + 11 + func TestMultiCritiquerCritiquesEachRecipe(t *testing.T) { 12 + t.Parallel() 13 + 14 + base := &stubCritiquer{ 15 + critique: &ai.RecipeCritique{ 16 + SchemaVersion: "recipe-critique-v1", 17 + OverallScore: 8, 18 + Summary: "Solid.", 19 + }, 20 + } 21 + mc := &multiCritiquer{ 22 + critiquer: base, 23 + } 24 + recipes := []ai.Recipe{ 25 + {Title: "One"}, 26 + {Title: "Two"}, 27 + } 28 + 29 + results := mc.CritiqueRecipes(t.Context(), recipes) 30 + 31 + var got []Result 32 + for result := range results { 33 + got = append(got, result) 34 + } 35 + mc.Wait() 36 + 37 + if len(got) != len(recipes) { 38 + t.Fatalf("results = %d, want %d", len(got), len(recipes)) 39 + } 40 + if base.calls != len(recipes) { 41 + t.Fatalf("calls = %d, want %d", base.calls, len(recipes)) 42 + } 43 + } 44 + 45 + func TestNewServiceReturnsRubberstampWithoutGemini(t *testing.T) { 46 + t.Parallel() 47 + 48 + svc := NewManager(&config.Config{}, cache.NewFileCache(t.TempDir())) 49 + 50 + results := svc.CritiqueRecipes(t.Context(), []ai.Recipe{{Title: "Weeknight Pasta"}}) 51 + result, ok := <-results 52 + if !ok { 53 + t.Fatal("expected critique result") 54 + } 55 + 56 + if result.Critique == nil || result.Critique.OverallScore != 10 { 57 + t.Fatalf("unexpected rubberstamp critique: %#v", result.Critique) 58 + } 59 + }
+56
internal/recipes/critique/store.go
··· 1 + package critique 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + ) 11 + 12 + const cachePrefix = "recipe_critiques/" 13 + 14 + func cacheKey(hash string) string { 15 + return cachePrefix + hash 16 + } 17 + 18 + type store struct { 19 + cache cache.ListCache 20 + } 21 + 22 + func NewStore(c cache.ListCache) store { 23 + if c == nil { 24 + panic("cache must not be nil") 25 + } 26 + return store{cache: c} 27 + } 28 + 29 + func (s store) Load(ctx context.Context, hash string) (*ai.RecipeCritique, error) { 30 + critiqueReader, err := s.cache.Get(ctx, cacheKey(hash)) 31 + if err != nil { 32 + return nil, err 33 + } 34 + defer func() { 35 + _ = critiqueReader.Close() 36 + }() 37 + 38 + var critique ai.RecipeCritique 39 + err = json.NewDecoder(critiqueReader).Decode(&critique) 40 + return &critique, err 41 + } 42 + 43 + func (s store) Save(ctx context.Context, hash string, critique *ai.RecipeCritique) error { 44 + if critique == nil { 45 + return fmt.Errorf("recipe critique is required") 46 + } 47 + body, err := json.Marshal(critique) 48 + if err != nil { 49 + return err 50 + } 51 + return s.cache.Put(ctx, cacheKey(hash), string(body), cache.Unconditional()) 52 + } 53 + 54 + func (s store) ListHashes(ctx context.Context) ([]string, error) { 55 + return s.cache.List(ctx, cachePrefix, "") 56 + }
+70
internal/recipes/critique/store_test.go
··· 1 + package critique 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + ) 11 + 12 + func TestStoreSaveUsesPrefixedKey(t *testing.T) { 13 + t.Parallel() 14 + 15 + tmpDir := t.TempDir() 16 + cacheStore := cache.NewFileCache(tmpDir) 17 + 18 + hash := "recipe-hash" 19 + want := &ai.RecipeCritique{ 20 + SchemaVersion: "recipe-critique-v1", 21 + OverallScore: 8, 22 + Summary: "Strong draft.", 23 + Strengths: []string{"balanced"}, 24 + Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "clarity", Detail: "One step could be tighter."}}, 25 + SuggestedFixes: []string{"tighten one step"}, 26 + } 27 + 28 + s := NewStore(cacheStore) 29 + 30 + if err := s.Save(t.Context(), hash, want); err != nil { 31 + t.Fatalf("Save failed: %v", err) 32 + } 33 + 34 + if _, err := os.Stat(filepath.Join(tmpDir, cachePrefix, hash)); err != nil { 35 + t.Fatalf("expected recipe critique at prefixed key: %v", err) 36 + } 37 + 38 + got, err := s.Load(t.Context(), hash) 39 + if err != nil { 40 + t.Fatalf("Load failed: %v", err) 41 + } 42 + if got.Summary != want.Summary { 43 + t.Fatalf("unexpected cached critique: %#v", got) 44 + } 45 + } 46 + 47 + func TestStoreListHashes(t *testing.T) { 48 + t.Parallel() 49 + 50 + cacheStore := cache.NewFileCache(t.TempDir()) 51 + s := NewStore(cacheStore) 52 + for _, hash := range []string{"b", "a"} { 53 + if err := s.Save(t.Context(), hash, &ai.RecipeCritique{ 54 + SchemaVersion: "recipe-critique-v1", 55 + OverallScore: 7, 56 + Summary: hash, 57 + }); err != nil { 58 + t.Fatalf("Save(%q) failed: %v", hash, err) 59 + } 60 + } 61 + 62 + hashes, err := s.ListHashes(t.Context()) 63 + if err != nil { 64 + t.Fatalf("ListHashes failed: %v", err) 65 + } 66 + 67 + if len(hashes) != 2 || hashes[0] != "a" || hashes[1] != "b" { 68 + t.Fatalf("unexpected hashes: %#v", hashes) 69 + } 70 + }
+33 -265
internal/recipes/generator.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/base64" 6 - "errors" 7 6 "fmt" 8 7 "hash/fnv" 9 8 "io" 10 9 "log/slog" 11 10 "slices" 12 11 "strings" 13 - "sync" 14 12 "time" 15 13 16 14 "careme/internal/ai" 17 - "careme/internal/cache" 18 - "careme/internal/config" 19 15 "careme/internal/kroger" 20 16 "careme/internal/locations" 21 17 "careme/internal/parallelism" 18 + "careme/internal/recipes/critique" 22 19 "careme/internal/wholefoods" 23 20 24 21 "github.com/samber/lo" 25 - "github.com/samber/lo/mutable" 26 22 ) 27 23 28 24 type aiClient interface { ··· 31 27 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 32 28 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 33 29 PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) 34 - Ready(ctx context.Context) error 35 30 } 36 31 37 - type multiCritiquier interface { 38 - CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan recipeCritiqueResult 32 + type staplesService interface { 33 + GetStaples(ctx context.Context, p *GeneratorParams) ([]kroger.Ingredient, error) 34 + // only used for wine. Probably need a refactoro 35 + GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]kroger.Ingredient, error) 39 36 } 40 37 41 - // TODO move this out of generator.go 42 - type multiCritiquierPlus interface { 43 - multiCritiquier 44 - Wait() 45 - Ready(ctx context.Context) error 38 + // TODO unexport? 39 + type Generator struct { 40 + aiClient aiClient 41 + critiquer critique.Service 42 + staples staplesService 46 43 } 47 44 48 - type rubberstamp struct{} 49 - 50 - func (r rubberstamp) CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan recipeCritiqueResult { 51 - results := make(chan recipeCritiqueResult, len(recipes)) 52 - for _, r := range recipes { 53 - results <- recipeCritiqueResult{ 54 - Critique: &ai.RecipeCritique{OverallScore: 10}, 55 - Recipe: &r, 56 - err: nil, 57 - } 45 + func NewGenerator(aiClient aiClient, critiquer critique.Service, staples staplesService) (*Generator, error) { 46 + if aiClient == nil { 47 + return nil, fmt.Errorf("ai client is required") 58 48 } 59 - close(results) 60 - return results 61 - } 62 - 63 - func (r rubberstamp) Wait() {} 64 - func (r rubberstamp) Ready(ctx context.Context) error { return nil } 65 - 66 - type MultiCritiquer struct { 67 - critiquer recipeCritiquer 68 - wg sync.WaitGroup 69 - } 70 - 71 - func (mc *MultiCritiquer) Ready(ctx context.Context) error { 72 - return mc.critiquer.Ready(ctx) 73 - } 74 - 75 - func NewMultiCritiquer(cfg *config.Config, cache cache.Cache) multiCritiquierPlus { 76 - if !cfg.Gemini.IsEnabled() { 77 - return rubberstamp{} 49 + if critiquer == nil { 50 + return nil, fmt.Errorf("critiquer is required") 78 51 } 79 - crit := ai.NewCritiquer(cfg.Gemini.APIKey, cfg.Gemini.CritiqueModel) 80 - cachingCritiquer := newCachingCritiquer(crit, cache) 81 - return &MultiCritiquer{critiquer: cachingCritiquer} 82 - } 83 - 84 - func (mc *MultiCritiquer) CritiqueRecipes(ctx context.Context, recipes []ai.Recipe) <-chan recipeCritiqueResult { 85 - results := make(chan recipeCritiqueResult, len(recipes)) 86 - mc.wg.Add(len(recipes)) 87 - 88 - var localWg sync.WaitGroup 89 - for _, recipe := range recipes { 90 - localWg.Go(func() { 91 - defer mc.wg.Done() 92 - critique, err := mc.critiquer.CritiqueRecipe(ctx, recipe) 93 - results <- recipeCritiqueResult{ 94 - Recipe: &recipe, 95 - Critique: critique, 96 - err: err, 97 - } 98 - }) 52 + if staples == nil { 53 + return nil, fmt.Errorf("staples service is required") 99 54 } 100 - go func() { 101 - localWg.Wait() 102 - close(results) 103 - }() 104 - return results 105 - } 106 - 107 - func (mc *MultiCritiquer) Wait() { 108 - mc.wg.Wait() 109 - } 110 - 111 - type ingredientio interface { 112 - SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error 113 - IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) 114 - } 115 - 116 - const minimumRecipeCritiqueScore = 8 117 - 118 - type Generator struct { 119 - config *config.Config 120 - aiClient aiClient 121 - critiquer multiCritiquier 122 - staplesProvider staplesProvider 123 - // TODO move ingrededientio into staples provider and remove from generator. 124 - io ingredientio 125 - } 126 - 127 - // this is kind of a factory. Could instead take stapes/criqiue and ai client isntead of creating them 128 - func NewGenerator(cfg *config.Config, cache cache.Cache, mc multiCritiquier) (generatorPlus, error) { 129 - if cfg.Mocks.Enable { 130 - return mock{}, nil 131 - } 132 - 133 - stapesProvider, err := NewStaplesProvider(cfg) 134 - if err != nil { 135 - return nil, fmt.Errorf("failed to create staples provider: %w", err) 136 - } 137 - 138 55 return &Generator{ 139 - io: IO(cache), 140 - config: cfg, 141 - aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), 142 - staplesProvider: stapesProvider, 143 - critiquer: mc, 56 + aiClient: aiClient, 57 + critiquer: critiquer, 58 + staples: staples, 144 59 }, nil 145 60 } 146 61 ··· 148 63 var styles []string 149 64 for _, style := range recipe.WineStyles { 150 65 style = strings.TrimSpace(style) 151 - if style != "" { // would this ever happen? 66 + if style != "" { 152 67 styles = append(styles, style) 153 68 } 154 69 } 155 70 156 - // whole foods search not actually implmented hard code categories 157 71 if wholefoods.NewIdentityProvider().IsID(location) { 158 - styles = []string{"red-wine", "white-wine", "sparkling"} // rose 72 + styles = []string{"red-wine", "white-wine", "sparkling"} 159 73 } 160 74 161 75 if len(styles) == 0 { 162 76 return &ai.WineSelection{Commentary: "no wines styles for recipe", Wines: []ai.Ingredient{}}, nil 163 77 } 164 - dateStr := date.Format("2006-01-02") 165 - logger := slog.With("location", location, "date", dateStr) 166 78 167 79 wines, err := parallelism.Flatten(styles, func(style string) ([]kroger.Ingredient, error) { 168 - cacheKey := wineIngredientsCacheKey(style, location, date) 169 - winesOfStyle, err := g.io.IngredientsFromCache(ctx, cacheKey) 170 - if err == nil { 171 - logger.InfoContext(ctx, "Serving cached wines for style", "style", style, "count", len(winesOfStyle)) 172 - return winesOfStyle, nil 173 - } 174 - if !errors.Is(err, cache.ErrNotFound) { 175 - logger.ErrorContext(ctx, "Failed to read cached wines for style", "style", style, "error", err) 176 - } 177 - 178 - winesOfStyle, err = g.staplesProvider.GetIngredients(ctx, location, style, 0) 179 - if err != nil { 180 - slog.ErrorContext(ctx, "Failed to get ingredients for wine style", "style", style, "error", err) 181 - return nil, fmt.Errorf("failed to get ingredients for style %q: %w", style, err) 182 - } 183 - logger.InfoContext(ctx, "Found wines.", "style", style, "count", len(winesOfStyle)) 184 - 185 - if err := g.io.SaveIngredients(ctx, cacheKey, winesOfStyle); err != nil { 186 - logger.ErrorContext(ctx, "Failed to cache wines for style", "style", style, "error", err) 187 - } 188 - return winesOfStyle, nil 80 + return g.staples.GetIngredients(ctx, location, style, 0, date) 189 81 }) 190 82 if err != nil { 191 83 return nil, err ··· 209 101 210 102 if p.ConversationID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) { 211 103 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID) 212 - // these should both always be true. Warn if not because its a caching bug? 104 + // should never get a conversation id without instructions or saved/dismissed 105 + // could assert or warn on that 213 106 instructions := []string{p.Instructions} 214 - // TODO give more guidnance on how many recipes to generate here 215 107 for _, dismissed := range p.Dismissed { 216 108 instructions = append(instructions, "Passed on "+dismissed.Title) 217 109 } 218 110 for _, saved := range newlySaved(p.Saved, p.PriorSavedHashes) { 219 111 instructions = append(instructions, "Enjoyed and saved so don't repeat: "+saved) 220 112 } 113 + // TODO more guidance on how many recipes to generate? 221 114 222 115 shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ConversationID) 223 116 if err != nil { ··· 227 120 if err != nil { 228 121 return nil, err 229 122 } 230 - // Include saved recipes in the shopping list 231 123 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) 232 124 233 125 slog.InfoContext(ctx, "regenerated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) 234 126 return shoppingList, nil 235 127 } 128 + 236 129 slog.InfoContext(ctx, "Generating recipes for location", "location", p.String()) 237 - ingredients, err := g.GetStaples(ctx, p) 130 + ingredients, err := g.staples.GetStaples(ctx, p) 238 131 if err != nil { 239 132 return nil, fmt.Errorf("failed to get staples: %w", err) 240 133 } 241 134 242 135 instructions := []string{p.Directive, p.Instructions} 243 - 244 136 shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, instructions, p.Date, p.LastRecipes) 245 137 if err != nil { 246 138 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) ··· 250 142 return nil, err 251 143 } 252 144 253 - // TODO this does not get saved in params and thus must be loaded from html 254 - // could update params after first generation or pregenerate before we save params. 255 145 p.ConversationID = shoppingList.ConversationID 256 146 slog.InfoContext(ctx, "generated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) 257 147 return shoppingList, nil ··· 265 155 return g.aiClient.GenerateRecipeImage(ctx, recipe) 266 156 } 267 157 268 - // calls get ingredients for a number of "staples" basically fresh produce and vegatbles. 269 - // tries to filter to no brand or certain brands to avoid shelved products 270 - func (g *Generator) GetStaples(ctx context.Context, p *generatorParams) ([]kroger.Ingredient, error) { 271 - lochash := p.LocationHash() 272 - 273 - if cachedIngredients, err := g.io.IngredientsFromCache(ctx, lochash); err == nil { 274 - slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(cachedIngredients)) 275 - return cachedIngredients, nil 276 - } else if !errors.Is(err, cache.ErrNotFound) { 277 - slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 278 - } 279 - 280 - ingredients, err := g.staplesProvider.FetchStaples(ctx, p.Location.ID) 281 - if err != nil { 282 - return nil, fmt.Errorf("failed to get ingredients for staples for %s: %w", p.Location.ID, err) 283 - } 284 - // should this be pushed down into staple proivder? go off product id? 285 - ingredients = uniqueByDescription(ingredients) 286 - 287 - mutable.Shuffle(ingredients) 288 - 289 - if err := g.io.SaveIngredients(ctx, p.LocationHash(), ingredients); err != nil { 290 - slog.ErrorContext(ctx, "failed to cache ingredients", "location", p.String(), "error", err) 291 - return nil, err 292 - } 293 - return ingredients, nil 294 - } 295 - 296 - // TODO should we be going off product id instead? 297 158 func uniqueByDescription(ingredients []kroger.Ingredient) []kroger.Ingredient { 298 159 return lo.UniqBy(ingredients, func(i kroger.Ingredient) string { 299 160 return toStr(i.Description) 300 161 }) 301 162 } 302 163 303 - // TODO pass in ai client so web.go can check aiclients readiness. 304 - func (g *Generator) Ready(ctx context.Context) error { 305 - if err := g.aiClient.Ready(ctx); err != nil { 306 - return err 307 - } 308 - return nil 309 - } 310 - 311 - // this is a little expnsive so unlike ready above needs to be protected by a once by. 312 - func (g *Generator) Watchdog(ctx context.Context) error { 313 - storeIDs := []string{ 314 - "wholefoods_10153", // bellevue 315 - "safeway_490", // bellevue 316 - "70500874", // qfc in bellevue 317 - "starmarket_3566", // boston 318 - "acmemarkets_806", // newark 319 - } 320 - _, err := parallelism.Flatten(storeIDs, func(storeID string) ([]kroger.Ingredient, error) { 321 - // defeats point of watch dog to read from cache but we could write to it as a courtesy. 322 - return g.staplesProvider.FetchStaples(ctx, storeID) 323 - }) 324 - 325 - return err 326 - } 327 - 328 - // toStr returns the string value if non-nil, or "empty" otherwise. 329 164 func toStr(s *string) string { 330 165 if s == nil { 331 166 return "empty" ··· 354 189 return lo.Uniq(titles) 355 190 } 356 191 357 - type recipeCritiqueResult struct { 358 - Recipe *ai.Recipe // just here so we can give the model the title 359 - Critique *ai.RecipeCritique 360 - err error 361 - } 362 - 363 192 func (g *Generator) critiqueAndMaybeRetry(ctx context.Context, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 364 193 if g.critiquer == nil { 365 194 return shoppingList, nil 366 195 } 367 196 368 197 results := g.critiquer.CritiqueRecipes(ctx, shoppingList.Recipes) 369 - var garbage []recipeCritiqueResult 370 - var good []ai.Recipe 371 - for result := range results { 372 - if result.err != nil { 373 - slog.ErrorContext(ctx, "failed to critique recipe", "hash", result.Recipe.ComputeHash(), "title", result.Recipe.Title, "error", result.err) 374 - good = append(good, *result.Recipe) 375 - continue 376 - } 377 - 378 - if result.Critique.OverallScore >= minimumRecipeCritiqueScore { 379 - good = append(good, *result.Recipe) 380 - continue 381 - } 382 - // if there are no issues should we still retry? wasted of tokens 198 + good, garbage := critique.Split(ctx, results, critique.MinimumRecipeScore) 199 + for _, result := range garbage { 383 200 slog.InfoContext(ctx, "low scoring recipe", "hash", result.Recipe.ComputeHash(), "title", result.Recipe.Title, "score", result.Critique.OverallScore) 384 - garbage = append(garbage, result) 385 - 386 201 } 387 202 if len(garbage) == 0 { 388 203 return shoppingList, nil 389 204 } 390 205 slog.InfoContext(ctx, "regenerating recipes based on critique feedback", "garbage_count", len(garbage), "good_count", len(good)) 391 206 392 - // store the garbage ones for reference 393 - 394 207 if strings.TrimSpace(shoppingList.ConversationID) == "" { 395 208 return nil, fmt.Errorf("conversation ID is required for critique retry") 396 209 } 397 210 398 - shoppingList, err := g.aiClient.Regenerate(ctx, critiqueRetryInstructions(garbage), shoppingList.ConversationID) 211 + // we could also just give all feedback back if any are below score 212 + shoppingList, err := g.aiClient.Regenerate(ctx, critique.RetryInstructions(garbage), shoppingList.ConversationID) 399 213 if err != nil { 400 214 return nil, fmt.Errorf("failed to regenerate recipes from critique feedback: %w", err) 401 215 } 402 216 newRecipes := shoppingList.Recipes 403 217 shoppingList.Recipes = append(shoppingList.Recipes, good...) 404 - shoppingList.Discarded = lo.Map(garbage, func(result recipeCritiqueResult, _ int) ai.Recipe { 218 + shoppingList.Discarded = lo.Map(garbage, func(result critique.Result, _ int) ai.Recipe { 405 219 return *result.Recipe 406 220 }) 407 221 408 - // fire this off async 409 222 _ = g.critiquer.CritiqueRecipes(ctx, newRecipes) 410 223 return shoppingList, nil 411 224 } 412 - 413 - func critiqueRetryInstructions(results []recipeCritiqueResult) []string { 414 - // first shot really wanted to explain corrections in description. Should we add another debug field for that? 415 - 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)) 416 - instructions := []string{revise} 417 - for _, result := range results { 418 - // do we care about summar or is it just a wast of tokens 419 - instructions = append(instructions, fmt.Sprintf( 420 - "Recipe %q scored %d/10.\n Issues: %s\n Suggested fixes: %s", 421 - result.Recipe.Title, 422 - result.Critique.OverallScore, 423 - // strings.TrimSpace(result.Critique.Summary), 424 - formatCritiqueIssues(result.Critique.Issues), 425 - formatSuggestedFixes(result.Critique.SuggestedFixes), 426 - )) 427 - } 428 - return instructions 429 - } 430 - 431 - func formatCritiqueIssues(issues []ai.RecipeCritiqueIssue) string { 432 - if len(issues) == 0 { 433 - return "none listed." 434 - } 435 - parts := make([]string, 0, len(issues)) 436 - for _, issue := range issues { 437 - parts = append(parts, fmt.Sprintf("[%s/%s] %s", issue.Category, issue.Severity, strings.TrimSpace(issue.Detail))) 438 - } 439 - return strings.Join(parts, "; ") 440 - } 441 - 442 - func formatSuggestedFixes(fixes []string) string { 443 - if len(fixes) == 0 { 444 - return "none listed." 445 - } 446 - trimmed := make([]string, 0, len(fixes)) 447 - for _, fix := range fixes { 448 - if fix = strings.TrimSpace(fix); fix != "" { 449 - trimmed = append(trimmed, fix) 450 - } 451 - } 452 - if len(trimmed) == 0 { 453 - return "none listed." 454 - } 455 - return strings.Join(trimmed, "; ") 456 - }
+49 -54
internal/recipes/generator_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "reflect" 5 6 "slices" 6 7 "sync" 7 8 "testing" ··· 11 12 "careme/internal/cache" 12 13 "careme/internal/kroger" 13 14 "careme/internal/locations" 15 + "careme/internal/recipes/critique" 14 16 ) 15 17 16 18 type captureWineQuestionAIClient struct { ··· 41 43 regenerateResponses []*ai.ShoppingList 42 44 } 43 45 44 - type captureCritiquer struct { 46 + type captureCritiqueService struct { 45 47 mu sync.Mutex 46 48 err error 47 49 recipes []ai.Recipe ··· 187 189 return nil 188 190 } 189 191 190 - func (c *captureCritiquer) CritiqueRecipe(ctx context.Context, recipe ai.Recipe) (*ai.RecipeCritique, error) { 191 - c.mu.Lock() 192 - c.recipes = append(c.recipes, recipe) 193 - c.mu.Unlock() 192 + func (c *captureCritiqueService) CritiqueRecipes(_ context.Context, recipes []ai.Recipe) <-chan critique.Result { 193 + results := make(chan critique.Result, len(recipes)) 194 + for _, recipe := range recipes { 195 + c.mu.Lock() 196 + c.recipes = append(c.recipes, recipe) 197 + c.mu.Unlock() 198 + 199 + crit, err := c.critiqueFor(recipe) 200 + results <- critique.Result{ 201 + Recipe: &recipe, 202 + Critique: crit, 203 + Err: err, 204 + } 205 + } 206 + close(results) 207 + return results 208 + } 209 + 210 + func (c *captureCritiqueService) critiqueFor(recipe ai.Recipe) (*ai.RecipeCritique, error) { 194 211 if c.err != nil { 195 212 return nil, c.err 196 213 } ··· 199 216 } 200 217 return &ai.RecipeCritique{ 201 218 SchemaVersion: "recipe-critique-v1", 202 - OverallScore: minimumRecipeCritiqueScore, 219 + OverallScore: 10, 203 220 Summary: "Solid draft.", 204 221 Strengths: []string{"clear direction"}, 205 222 Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "timing", Detail: "Timing could be tighter."}}, 206 223 SuggestedFixes: []string{"tighten the timing"}, 207 224 }, nil 208 - } 209 - 210 - func (c *captureCritiquer) Ready(ctx context.Context) error { 211 - return c.err 212 225 } 213 226 214 227 func (s *captureWineStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { ··· 263 276 }, 264 277 } 265 278 g := &Generator{ 266 - io: IO(cacheStore), 279 + staples: &cachedStaplesService{cache: rio, provider: &captureWineStaplesProvider{}}, 267 280 aiClient: aiStub, 268 281 } 269 282 ··· 309 322 }, 310 323 } 311 324 g := &Generator{ 312 - io: IO(cache.NewFileCache(t.TempDir())), 313 - aiClient: aiStub, 314 - staplesProvider: staplesStub, 325 + staples: &cachedStaplesService{cache: IO(cache.NewFileCache(t.TempDir())), provider: staplesStub}, 326 + aiClient: aiStub, 315 327 } 316 328 317 329 got, err := g.PickAWine(t.Context(), "wholefoods_10216", ai.Recipe{ ··· 349 361 }, 350 362 } 351 363 g := &Generator{ 352 - io: IO(cache.NewFileCache(t.TempDir())), 353 364 aiClient: aiStub, 354 365 } 355 366 ··· 384 395 } 385 396 } 386 397 387 - func TestGenerateRecipes_SavesCritiquesForGeneratedRecipes(t *testing.T) { 398 + func TestGenerateRecipes_CritiquesGeneratedRecipes(t *testing.T) { 388 399 generated := []ai.Recipe{ 389 400 {Title: "Roast Chicken", Description: "Crisp and simple", Instructions: []string{"Roast the chicken."}}, 390 401 {Title: "Pasta Primavera", Description: "Vegetable pasta", Instructions: []string{"Boil pasta.", "Toss with vegetables."}}, ··· 403 414 Recipes: generated, 404 415 }, 405 416 } 406 - critiquer := &captureCritiquer{} 407 - cachedCrit := newCachingCritiquer(critiquer, cacheStore) 408 - mc := &MultiCritiquer{critiquer: cachedCrit} 417 + critiquer := &captureCritiqueService{} 409 418 g := &Generator{ 410 - io: io, 419 + staples: &cachedStaplesService{cache: io}, 411 420 aiClient: aiStub, 412 - critiquer: mc, 421 + critiquer: critiquer, 413 422 } 414 423 415 424 got, err := g.GenerateRecipes(t.Context(), params) ··· 422 431 if len(critiquer.recipes) != len(generated) { 423 432 t.Fatalf("expected %d critiques, got %d", len(generated), len(critiquer.recipes)) 424 433 } 425 - for _, recipe := range generated { 426 - critique, err := io.CritiqueFromCache(t.Context(), recipe.ComputeHash()) 427 - if err != nil { 428 - t.Fatalf("expected critique for %q: %v", recipe.Title, err) 429 - } 430 - if critique.Summary != "Solid draft." { 431 - t.Fatalf("unexpected critique summary for %q: %#v", recipe.Title, critique) 432 - } 434 + if !reflect.DeepEqual(critiquer.recipes, generated) { 435 + t.Fatalf("unexpected critiqued recipes: got %+v want %+v", critiquer.recipes, generated) 433 436 } 434 437 } 435 438 ··· 437 440 alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 438 441 newResult := ai.Recipe{Title: "Brand New Dinner", Description: "Fresh idea"} 439 442 440 - critiquer := &captureCritiquer{} 443 + critiquer := &captureCritiqueService{} 441 444 g := &Generator{ 442 - io: IO(cache.NewInMemoryCache()), 443 445 aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ConversationID: "conv-123", Recipes: []ai.Recipe{newResult}}}, 444 - critiquer: &MultiCritiquer{critiquer: critiquer}, 446 + critiquer: critiquer, 445 447 } 446 448 447 449 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) ··· 481 483 Recipes: []ai.Recipe{retried}, 482 484 }}, 483 485 } 484 - critiquer := &captureCritiquer{ 486 + critiquer := &captureCritiqueService{ 485 487 fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 486 488 switch recipe.Title { 487 489 case "Weak Dinner": ··· 509 511 }, 510 512 } 511 513 512 - mc := &MultiCritiquer{critiquer: newCachingCritiquer(critiquer, cacheStore)} 513 514 g := &Generator{ 514 - io: io, 515 + staples: &cachedStaplesService{cache: io}, 515 516 aiClient: aiStub, 516 - critiquer: mc, 517 + critiquer: critiquer, 517 518 } 518 519 519 520 got, err := g.GenerateRecipes(t.Context(), params) ··· 539 540 if got := aiStub.regenerateConversation; !slices.Equal(got, []string{"conv-initial"}) { 540 541 t.Fatalf("unexpected critique retry conversation IDs: got %v", got) 541 542 } 542 - mc.Wait() 543 543 if len(critiquer.recipes) != 2 { 544 544 t.Fatalf("expected two critique passes, got %d", len(critiquer.recipes)) 545 545 } 546 - if critique, err := io.CritiqueFromCache(t.Context(), retried.ComputeHash()); err != nil { 547 - t.Fatalf("expected cached critique for retried recipe: %v", err) 548 - } else if critique.OverallScore != 8 { 549 - t.Fatalf("expected final critique score 8, got %d", critique.OverallScore) 546 + if got := critiquer.recipes[1].Title; got != "Better Dinner" { 547 + t.Fatalf("expected retried recipe to be critiqued again, got %q", got) 550 548 } 551 549 } 552 550 ··· 572 570 Recipes: []ai.Recipe{retried}, 573 571 }}, 574 572 } 575 - critiquer := &captureCritiquer{ 573 + critiquer := &captureCritiqueService{ 576 574 fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 577 575 switch recipe.Title { 578 576 case "Weak Dinner": ··· 600 598 }, 601 599 } 602 600 g := &Generator{ 603 - io: io, 601 + staples: &cachedStaplesService{cache: io}, 604 602 aiClient: aiStub, 605 - critiquer: &MultiCritiquer{critiquer: critiquer}, 603 + critiquer: critiquer, 606 604 } 607 605 608 606 got, err := g.GenerateRecipes(t.Context(), params) ··· 634 632 }}, 635 633 } 636 634 g := &Generator{ 637 - io: io, 635 + staples: &cachedStaplesService{cache: io}, 638 636 aiClient: aiStub, 639 - critiquer: &MultiCritiquer{critiquer: &captureCritiquer{}}, 637 + critiquer: &captureCritiqueService{}, 640 638 } 641 639 642 640 got, err := g.GenerateRecipes(t.Context(), params) ··· 656 654 initial := ai.Recipe{Title: "Needs Work", Description: "First pass"} 657 655 retried := ai.Recipe{Title: "Ready Now", Description: "Second pass"} 658 656 659 - cacheStore := cache.NewFileCache(t.TempDir()) 660 - io := IO(cacheStore) 661 657 aiStub := &sequenceAIClient{ 662 658 regenerateResponses: []*ai.ShoppingList{ 663 659 { ··· 670 666 }, 671 667 }, 672 668 } 673 - critiquer := &captureCritiquer{ 669 + critiquer := &captureCritiqueService{ 674 670 fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 675 671 switch recipe.Title { 676 672 case "Needs Work": ··· 698 694 }, 699 695 } 700 696 g := &Generator{ 701 - io: io, 702 697 aiClient: aiStub, 703 - critiquer: &MultiCritiquer{critiquer: critiquer}, 698 + critiquer: critiquer, 704 699 } 705 700 706 701 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) ··· 757 752 Recipes: []ai.Recipe{retried}, 758 753 }}, 759 754 } 760 - critiquer := &captureCritiquer{ 755 + critiquer := &captureCritiqueService{ 761 756 fn: func(recipe ai.Recipe) (*ai.RecipeCritique, error) { 762 757 return &ai.RecipeCritique{ 763 758 SchemaVersion: "recipe-critique-v1", ··· 770 765 }, 771 766 } 772 767 g := &Generator{ 773 - io: io, 768 + staples: &cachedStaplesService{cache: io}, 774 769 aiClient: aiStub, 775 - critiquer: &MultiCritiquer{critiquer: critiquer}, 770 + critiquer: critiquer, 776 771 } 777 772 778 773 got, err := g.GenerateRecipes(t.Context(), params)
-32
internal/recipes/io_test.go
··· 233 233 } 234 234 } 235 235 236 - func TestSaveCritique_UsesPrefixedKey(t *testing.T) { 237 - tmpDir := t.TempDir() 238 - cacheStore := cache.NewFileCache(tmpDir) 239 - rio := IO(cacheStore) 240 - 241 - hash := "recipe-hash" 242 - critique := &ai.RecipeCritique{ 243 - SchemaVersion: "recipe-critique-v1", 244 - OverallScore: 8, 245 - Summary: "Strong draft.", 246 - Strengths: []string{"balanced"}, 247 - Issues: []ai.RecipeCritiqueIssue{{Severity: "low", Category: "clarity", Detail: "One step could be tighter."}}, 248 - SuggestedFixes: []string{"tighten one step"}, 249 - } 250 - 251 - if err := rio.SaveCritique(t.Context(), hash, critique); err != nil { 252 - t.Fatalf("SaveCritique failed: %v", err) 253 - } 254 - 255 - if _, err := os.Stat(filepath.Join(tmpDir, recipeCritiquesCachePrefix, hash)); err != nil { 256 - t.Fatalf("expected recipe critique at prefixed key: %v", err) 257 - } 258 - 259 - got, err := rio.CritiqueFromCache(t.Context(), hash) 260 - if err != nil { 261 - t.Fatalf("CritiqueFromCache failed: %v", err) 262 - } 263 - if got.Summary != "Strong draft." { 264 - t.Fatalf("unexpected cached critique: %#v", got) 265 - } 266 - } 267 - 268 236 func loPtr(v string) *string { 269 237 return &v 270 238 }
+4 -8
internal/recipes/mock.go
··· 15 15 16 16 type mock struct{} 17 17 18 + func NewMockGenerator() generator { 19 + return mock{} 20 + } 21 + 18 22 var mockRecipeImage = []byte{ 19 23 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 20 24 0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R', ··· 357 361 "bake until cheese melts", 358 362 }, 359 363 }, 360 - } 361 - 362 - func (m mock) Ready(ctx context.Context) error { 363 - return nil 364 - } 365 - 366 - func (m mock) Watchdog(ctx context.Context) error { 367 - return nil 368 364 } 369 365 370 366 func (m mock) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) {
-1
internal/recipes/params.go
··· 130 130 131 131 p := DefaultParams(l, date) 132 132 p.Instructions = r.URL.Query().Get("instructions") 133 - // should this be in hash? 134 133 p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 135 134 136 135 return p, nil
+18
internal/recipes/params_test.go
··· 65 65 } 66 66 } 67 67 68 + func TestParseQueryArgs_PopulatesConversationID(t *testing.T) { 69 + location := &locations.Location{ 70 + ID: "store-1", 71 + Name: "Test Store", 72 + ZipCode: "10001", 73 + } 74 + 75 + req := httptest.NewRequest("GET", "/recipes?location=store-1&conversation_id=conv-123", nil) 76 + p, err := ParseQueryArgs(context.Background(), req, staticLocationLookup{location: location}) 77 + if err != nil { 78 + t.Fatalf("ParseQueryArgs returned error: %v", err) 79 + } 80 + 81 + if got, want := p.ConversationID, "conv-123"; got != want { 82 + t.Fatalf("expected conversation id %q, got %q", want, got) 83 + } 84 + } 85 + 68 86 func TestTimezoneNameForZip(t *testing.T) { 69 87 cases := []struct { 70 88 zip string
+1 -6
internal/recipes/server.go
··· 79 79 PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) 80 80 } 81 81 82 - // should we have new generator just return two interfaces instead of gluing? 83 - type generatorPlus interface { 84 - generator 85 - Ready(ctx context.Context) error 86 - Watchdog(ctx context.Context) error 87 - } 82 + type ExtGenerator = generator 88 83 89 84 type server struct { 90 85 recipeio
+41 -6
internal/recipes/server_test.go
··· 638 638 } 639 639 640 640 type captureQuestionGenerator struct { 641 - lastQuestion string 642 - lastWinePick struct { 641 + lastQuestion string 642 + lastConversationID string 643 + lastWinePick struct { 643 644 recipeTitle string 644 645 date time.Time 645 646 } ··· 657 658 658 659 func (c *captureQuestionGenerator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 659 660 c.lastQuestion = question 661 + c.lastConversationID = conversationID 660 662 return "Try chicken thighs at the same cook time.", nil 661 663 } 662 664 ··· 694 696 return nil 695 697 } 696 698 699 + func seedQuestionConversation(t *testing.T, s *server, conversationID string) string { 700 + t.Helper() 701 + 702 + p := DefaultParams(&locations.Location{ID: "70003002", Name: "Question Test Store"}, time.Now()) 703 + originHash := p.Hash() 704 + if err := s.SaveParams(t.Context(), p); err != nil { 705 + t.Fatalf("failed to save params: %v", err) 706 + } 707 + recipe := ai.Recipe{ 708 + OriginHash: originHash, 709 + Title: "Roast Chicken", 710 + Description: "Crisp skin and herbs.", 711 + Ingredients: []ai.Ingredient{{Name: "chicken", Quantity: "1", Price: "$12"}}, 712 + Instructions: []string{"Roast until done."}, 713 + } 714 + recipeHash := recipe.ComputeHash() 715 + saveRecipesForOrigin(t, s, originHash, recipe) 716 + if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 717 + Recipes: []ai.Recipe{recipe}, 718 + ConversationID: conversationID, 719 + }, originHash); err != nil { 720 + t.Fatalf("failed to save shopping list: %v", err) 721 + } 722 + return recipeHash 723 + } 724 + 697 725 func TestHandleQuestion_HTMXReturnsThreadFragment(t *testing.T) { 698 726 cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 699 727 s := newTestServer(t, ··· 701 729 withTestGenerator(&captureQuestionGenerator{}), 702 730 ) 703 731 732 + recipeHash := seedQuestionConversation(t, s, "conv-test") 733 + 704 734 form := url.Values{ 705 735 "conversation_id": {"conv-test"}, 706 736 "question": {"Can I swap the protein?"}, 707 737 } 708 - req := httptest.NewRequest(http.MethodPost, "/recipe/hash/question", strings.NewReader(form.Encode())) 738 + req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/question", strings.NewReader(form.Encode())) 709 739 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 710 740 req.Header.Set("HX-Request", "true") 711 - req.SetPathValue("hash", "hash") 741 + req.SetPathValue("hash", recipeHash) 712 742 rr := httptest.NewRecorder() 713 743 714 744 s.handleQuestion(rr, req) ··· 728 758 } 729 759 if !strings.Contains(body, "Try chicken thighs at the same cook time.") { 730 760 t.Fatalf("expected answer in response, got body: %s", body) 761 + } 762 + if got, want := s.generator.(*captureQuestionGenerator).lastConversationID, "conv-test"; got != want { 763 + t.Fatalf("expected generator conversation ID %q, got %q", want, got) 731 764 } 732 765 } 733 766 ··· 763 796 withTestGenerator(g), 764 797 ) 765 798 799 + recipeHash := seedQuestionConversation(t, s, "conv-test") 800 + 766 801 form := url.Values{ 767 802 "conversation_id": {"conv-test"}, 768 803 "question": {"Can I swap the protein?"}, 769 804 "recipe_title": {"BBQ Pulled Pork"}, 770 805 } 771 - req := httptest.NewRequest(http.MethodPost, "/recipe/hash/question", strings.NewReader(form.Encode())) 806 + req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/question", strings.NewReader(form.Encode())) 772 807 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 773 808 req.Header.Set("HX-Request", "true") 774 - req.SetPathValue("hash", "hash") 809 + req.SetPathValue("hash", recipeHash) 775 810 rr := httptest.NewRecorder() 776 811 777 812 s.handleQuestion(rr, req)
+91
internal/recipes/staples.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "log/slog" 6 8 "testing" 9 + "time" 7 10 8 11 "careme/internal/albertsons" 9 12 "careme/internal/brightdata" 13 + "careme/internal/cache" 10 14 "careme/internal/config" 11 15 "careme/internal/kroger" 16 + "careme/internal/parallelism" 12 17 "careme/internal/walmart" 13 18 "careme/internal/wholefoods" 19 + 20 + "github.com/samber/lo/mutable" 14 21 ) 15 22 16 23 // todo make this a indepenedent ingredient object not kroger. ··· 34 41 staplesProvider 35 42 } 36 43 44 + type ingredientio interface { 45 + SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error 46 + IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) 47 + } 48 + 49 + type cachedStaplesService struct { 50 + provider staplesProvider 51 + cache ingredientio 52 + } 53 + 37 54 func NewStaplesProvider(cfg *config.Config) (staplesProvider, error) { 38 55 kclient, err := kroger.FromConfig(cfg) 39 56 if err != nil { ··· 49 66 }, nil 50 67 } 51 68 69 + func NewCachedStaplesService(cfg *config.Config, c cache.Cache) (*cachedStaplesService, error) { 70 + provider, err := NewStaplesProvider(cfg) 71 + if err != nil { 72 + return nil, fmt.Errorf("failed to create staples provider: %w", err) 73 + } 74 + return &cachedStaplesService{ 75 + provider: provider, 76 + cache: IO(c), 77 + }, nil 78 + } 79 + 52 80 func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 53 81 provider, err := p.providerForLocation(locationID) 54 82 if err != nil { ··· 63 91 return nil, err 64 92 } 65 93 return provider.GetIngredients(ctx, locationID, searchTerm, skip) 94 + } 95 + 96 + func (s *cachedStaplesService) GetStaples(ctx context.Context, p *GeneratorParams) ([]kroger.Ingredient, error) { 97 + lochash := p.LocationHash() 98 + 99 + if cachedIngredients, err := s.cache.IngredientsFromCache(ctx, lochash); err == nil { 100 + slog.InfoContext(ctx, "serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(cachedIngredients)) 101 + return cachedIngredients, nil 102 + } else if !errors.Is(err, cache.ErrNotFound) { 103 + slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 104 + } 105 + 106 + ingredients, err := s.provider.FetchStaples(ctx, p.Location.ID) 107 + if err != nil { 108 + return nil, fmt.Errorf("failed to get ingredients for staples for %s: %w", p.Location.ID, err) 109 + } 110 + ingredients = uniqueByDescription(ingredients) 111 + mutable.Shuffle(ingredients) 112 + 113 + if err := s.cache.SaveIngredients(ctx, lochash, ingredients); err != nil { 114 + slog.ErrorContext(ctx, "failed to cache ingredients", "location", p.String(), "error", err) 115 + return nil, err 116 + } 117 + return ingredients, nil 118 + } 119 + 120 + func (s *cachedStaplesService) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]kroger.Ingredient, error) { 121 + cacheKey := wineIngredientsCacheKey(searchTerm, locationID, date) 122 + logger := slog.With("location", locationID, "date", date.Format("2006-01-02"), "style", searchTerm) 123 + 124 + wines, err := s.cache.IngredientsFromCache(ctx, cacheKey) 125 + if err == nil { 126 + logger.InfoContext(ctx, "serving cached ingredients", "count", len(wines)) 127 + return wines, nil 128 + } 129 + if !errors.Is(err, cache.ErrNotFound) { 130 + logger.ErrorContext(ctx, "failed to read cached ingredients", "error", err) 131 + } 132 + 133 + wines, err = s.provider.GetIngredients(ctx, locationID, searchTerm, skip) 134 + if err != nil { 135 + return nil, fmt.Errorf("failed to get ingredients for %q: %w", searchTerm, err) 136 + } 137 + logger.InfoContext(ctx, "found ingredients", "count", len(wines)) 138 + 139 + if err := s.cache.SaveIngredients(ctx, cacheKey, wines); err != nil { 140 + logger.ErrorContext(ctx, "failed to cache ingredients", "error", err) 141 + } 142 + return wines, nil 143 + } 144 + 145 + func (s *cachedStaplesService) Watchdog(ctx context.Context) error { 146 + storeIDs := []string{ 147 + "wholefoods_10153", 148 + "safeway_490", 149 + "70500874", 150 + "starmarket_3566", 151 + "acmemarkets_806", 152 + } 153 + _, err := parallelism.Flatten(storeIDs, func(storeID string) ([]kroger.Ingredient, error) { 154 + return s.provider.FetchStaples(ctx, storeID) 155 + }) 156 + return err 66 157 } 67 158 68 159 func staplesSignatureForLocation(locationID string) string {
+6 -5
internal/recipes/staples_test.go
··· 144 144 {Description: loPtr("Baby Spinach")}, 145 145 }, 146 146 } 147 - g := &Generator{ 148 - io: IO(cacheStore), 149 - staplesProvider: provider, 147 + s := &cachedStaplesService{ 148 + cache: IO(cacheStore), 149 + provider: provider, 150 150 } 151 + 151 152 params := &generatorParams{ 152 153 Location: &locations.Location{ID: "wholefoods_10216", Name: "Westlake"}, 153 154 Date: time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC), 154 155 } 155 156 156 - got, err := g.GetStaples(t.Context(), params) 157 + got, err := s.GetStaples(t.Context(), params) 157 158 if err != nil { 158 159 t.Fatalf("GetStaples returned error: %v", err) 159 160 } ··· 172 173 t.Fatalf("expected cached deduped results, got %d", len(cached)) 173 174 } 174 175 175 - gotAgain, err := g.GetStaples(t.Context(), params) 176 + gotAgain, err := s.GetStaples(t.Context(), params) 176 177 if err != nil { 177 178 t.Fatalf("GetStaples returned error on cached call: %v", err) 178 179 }