ai cooking
0
fork

Configure Feed

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

Merge pull request #139 from paulgmiller/pmiller/blobisgenerating

Spin if we can't find recipe

authored by

Paul Miller and committed by
GitHub
0d38887d ed714e9c

+111 -88
+13 -2
cmd/careme/mail.go
··· 11 11 "careme/internal/recipes" 12 12 "careme/internal/users" 13 13 "context" 14 + "errors" 14 15 "fmt" 15 16 "log/slog" 16 17 "os" ··· 130 131 } 131 132 p.LastRecipes = append(p.LastRecipes, last.Title) 132 133 } 134 + if err := rio.SaveParams(ctx, p); err != nil { 135 + if errors.Is(err, recipes.AlreadyExists) { 136 + slog.InfoContext(ctx, "params already exist, another process likely generated", "user", user.ID) 137 + return 138 + } 139 + 140 + slog.ErrorContext(ctx, "failed to save params", "error", err.Error()) 141 + return 142 + } 133 143 134 144 shoppingList, err := m.generator.GenerateRecipes(ctx, p) 135 145 if err != nil { 136 146 slog.ErrorContext(ctx, "failed to generate recipes for user", "user", user.Email) 137 147 return 138 148 } 139 - // coombine hee save recipes with html 140 - if err := rio.SaveShoppingList(ctx, shoppingList, p); err != nil { 149 + 150 + // combine hee save recipes with html 151 + if err := rio.SaveShoppingList(ctx, shoppingList, p.Hash()); err != nil { 141 152 slog.ErrorContext(ctx, "failed to save shopping list", "error", err.Error()) 142 153 return 143 154 }
+1
internal/cache/file.go
··· 13 13 14 14 type Cache interface { 15 15 Get(ctx context.Context, key string) (io.ReadCloser, error) 16 + //TODO define set behavior if it already exists. Maybe go immutable and error? 16 17 Set(ctx context.Context, key, value string) error 17 18 Exists(ctx context.Context, key string) (bool, error) 18 19 }
+4 -32
internal/recipes/generator.go
··· 8 8 "careme/internal/locations" 9 9 "context" 10 10 "encoding/json" 11 - "errors" 12 11 "fmt" 13 12 "log/slog" 14 13 "net/http" ··· 27 26 } 28 27 29 28 type Generator struct { 30 - config *config.Config 31 - aiClient aiClient 32 - krogerClient kroger.ClientWithResponsesInterface // probably need only subset 33 - cache cache.Cache 34 - inFlight map[string]struct{} 35 - generationLock sync.Mutex 29 + config *config.Config 30 + aiClient aiClient 31 + krogerClient kroger.ClientWithResponsesInterface // probably need only subset 32 + cache cache.Cache 36 33 } 37 34 38 35 func NewGenerator(cfg *config.Config, cache cache.Cache) (generator, error) { ··· 49 46 config: cfg, 50 47 aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), 51 48 krogerClient: client, 52 - inFlight: make(map[string]struct{}), 53 49 }, nil 54 50 } 55 51 56 - // eventually we want to use blob with exipiry for this 57 - func (g *Generator) isGenerating(hash string) (bool, func()) { 58 - g.generationLock.Lock() 59 - defer g.generationLock.Unlock() 60 - if _, exists := g.inFlight[hash]; exists { 61 - return true, nil 62 - } 63 - g.inFlight[hash] = struct{}{} 64 - return false, func() { 65 - g.generationLock.Lock() 66 - defer g.generationLock.Unlock() 67 - delete(g.inFlight, hash) 68 - } 69 - } 70 - 71 - var InProgress error = errors.New("generation in progress") 72 - 73 52 func (g *Generator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { 74 53 hash := p.Hash() 75 - generating, done := g.isGenerating(hash) 76 - if generating { 77 - slog.InfoContext(ctx, "Generation already in progress, skipping", "hash", hash) 78 - return nil, InProgress 79 - } 80 - defer done() 81 54 start := time.Now() 82 55 83 - var err error 84 56 if p.ConversationID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) { 85 57 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID) 86 58 // these should both always be true. Warn if not because its a caching bug?
+25 -14
internal/recipes/io.go
··· 83 83 return errors.Join(errs...) 84 84 } 85 85 86 - func (rio *recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, p *generatorParams) error { 87 - // Save each recipe separately by its hash 88 - if err := rio.SaveRecipes(ctx, shoppingList.Recipes, p.Hash()); err != nil { 86 + var AlreadyExists = errors.New("already exists") 87 + 88 + func (rio *recipeio) SaveParams(ctx context.Context, p *generatorParams) error { 89 + //not atomic push this into cache 90 + exists, err := rio.Cache.Exists(ctx, p.Hash()+".params") 91 + if err != nil { 92 + slog.ErrorContext(ctx, "failed to check existing params in cache", "location", p.String(), "error", err) 89 93 return err 90 94 } 91 - // we could actually nuke out the rest of recipe and lazily load but not yet 92 - shoppingJSON := lo.Must(json.Marshal(shoppingList)) 93 - if err := rio.Cache.Set(ctx, p.Hash(), string(shoppingJSON)); err != nil { 94 - slog.ErrorContext(ctx, "failed to cache shopping list document", "location", p.String(), "error", err) 95 - return err 95 + 96 + if exists { 97 + return AlreadyExists 96 98 } 97 99 98 - // Also cache the params for hash-based retrieval 99 - // TODO: Consider embedding the params directly in the shoppingList structure. 100 - // This would allow us to cache both the shopping list and its associated parameters together, 101 - // avoiding the need for a separate cache entry for params (currently stored as "<hash>.params"). 102 - // Embedding params could simplify cache management and ensure all relevant data is retrieved together. 103 - // Persist the latest conversation IDs with the params so follow-ups can reuse them. 104 100 paramsJSON := lo.Must(json.Marshal(p)) 105 101 if err := rio.Cache.Set(ctx, p.Hash()+".params", string(paramsJSON)); err != nil { 106 102 slog.ErrorContext(ctx, "failed to cache params", "location", p.String(), "error", err) ··· 108 104 } 109 105 return nil 110 106 } 107 + 108 + func (rio *recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, hash string) error { 109 + // Save each recipe separately by its hash 110 + if err := rio.SaveRecipes(ctx, shoppingList.Recipes, hash); err != nil { 111 + return err 112 + } 113 + // we could actually nuke out the rest of recipe and lazily load but not yet 114 + shoppingJSON := lo.Must(json.Marshal(shoppingList)) 115 + if err := rio.Cache.Set(ctx, hash, string(shoppingJSON)); err != nil { 116 + slog.ErrorContext(ctx, "failed to cache shopping list document", "hash", hash, "error", err) 117 + return err 118 + } 119 + 120 + return nil 121 + }
+68 -40
internal/recipes/server.go
··· 56 56 func (s *server) Register(mux *http.ServeMux) { 57 57 mux.HandleFunc("GET /recipes", s.handleRecipes) 58 58 mux.HandleFunc("GET /recipe/{hash}", s.handleSingle) 59 + //maybe this should be under locations server? 60 + mux.HandleFunc("GET /ingredients/{location}", s.ingredients) 61 + 59 62 } 60 63 61 64 func (s *server) handleSingle(w http.ResponseWriter, r *http.Request) { ··· 97 100 98 101 func (s *server) handleRecipes(w http.ResponseWriter, r *http.Request) { 99 102 ctx := r.Context() 100 - currentUser, err := users.FromRequest(r, s.storage) 101 - if err != nil { 102 - if errors.Is(err, users.ErrNotFound) { 103 - users.ClearCookie(w) 104 - http.Redirect(w, r, "/", http.StatusSeeOther) 105 - return 106 - } 107 - slog.ErrorContext(ctx, "failed to load user for recipes", "error", err) 108 - http.Error(w, "unable to load account", http.StatusInternalServerError) 109 - return 110 - } 111 - if currentUser == nil { 112 - currentUser = &users.User{LastRecipes: []users.Recipe{}} 113 - } 114 103 115 104 if hashParam := r.URL.Query().Get("h"); hashParam != "" { 116 - //TODO check if generating and spin. 117 - slist, err := s.FromCache(ctx, hashParam) 105 + 106 + slist, err := s.FromCache(ctx, hashParam) // ideally should memory cache this so lots of reloads don't constantly go out to azure 118 107 if err != nil { 108 + if errors.Is(err, cache.ErrNotFound) { 109 + //how do we time this out and go try and regenerate 110 + //should we put start time in params or a seperate blob 111 + s.Spin(w, r) 112 + return 113 + } 119 114 slog.ErrorContext(ctx, "failed to load recipe list for hash", "hash", hashParam, "error", err) 120 115 http.Error(w, "recipe not found or expired", http.StatusNotFound) 121 116 return ··· 144 139 // what do we do with this? 145 140 // p.UserID = currentUser.ID 146 141 147 - if r.URL.Query().Get("ingredients") == "true" { 148 - s.ingredients(ctx, w, p) 142 + //if params are already saved redirect and assume someone kicks off genration 143 + 144 + currentUser, err := users.FromRequest(r, s.storage) 145 + if err != nil { 146 + if errors.Is(err, users.ErrNotFound) { 147 + users.ClearCookie(w) 148 + http.Redirect(w, r, "/", http.StatusSeeOther) 149 + return 150 + } 151 + slog.ErrorContext(ctx, "failed to load user for recipes", "error", err) 152 + http.Error(w, "unable to load account", http.StatusInternalServerError) 153 + return 154 + } 155 + if currentUser == nil { 156 + currentUser = &users.User{LastRecipes: []users.Recipe{}} 157 + } 158 + 159 + if err := s.SaveParams(ctx, p); err != nil { 160 + if errors.Is(err, AlreadyExists) { 161 + slog.InfoContext(ctx, "params already existed redirecting", "hash", p.Hash()) 162 + redirectToHash(w, r, p.Hash()) 163 + return 164 + } 165 + slog.ErrorContext(ctx, "failed to save params", "error", err) 166 + http.Error(w, "internal server error", http.StatusInternalServerError) 149 167 return 150 168 } 151 169 170 + //After this failures lead to recipe orphaning. 171 + 172 + hash := p.Hash() 173 + 152 174 // Handle finalize - save recipes to user profile and display filtered list 153 175 if r.URL.Query().Get("finalize") == "true" { 154 176 // Check if user is authenticated ··· 177 199 ConversationID: p.ConversationID, 178 200 } 179 201 180 - // should finlize go into params to get a different hash that previous one with unsaved? 202 + // should finalize go into params to get a different hash that previous one with unsaved? 181 203 // or should we shove a guid or iteration in params along with conversation id. Response id? 182 - if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil { 204 + if err := s.SaveShoppingList(ctx, shoppingList, hash); err != nil { 183 205 slog.ErrorContext(ctx, "save error", "error", err) 206 + http.Error(w, "failed to save finalized recipes", http.StatusInternalServerError) 207 + return 184 208 } 185 - http.Redirect(w, r, "/recipes?h="+p.Hash(), http.StatusSeeOther) 186 - return 187 - } 188 - 189 - hash := p.Hash() 190 - if _, err := s.FromCache(ctx, hash); err == nil { 191 - // TODO check not found error explicitly 192 - http.Redirect(w, r, "/recipes?h="+p.Hash(), http.StatusSeeOther) 209 + redirectToHash(w, r, hash) 193 210 return 194 211 } 195 212 ··· 208 225 slog.InfoContext(ctx, "generating cached recipes", "params", p.String(), "hash", hash) 209 226 shoppingList, err := s.generator.GenerateRecipes(ctx, p) 210 227 if err != nil { 211 - if errors.Is(err, InProgress) { 212 - slog.InfoContext(ctx, "generation already in progress, skipping save", "hash", hash) 213 - return 214 - } 215 228 slog.ErrorContext(ctx, "generate error", "error", err) 216 229 return 217 230 } 218 231 219 232 // add saved recipes here rather than each 220 233 221 - if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil { 234 + if err := s.SaveShoppingList(ctx, shoppingList, hash); err != nil { 222 235 slog.ErrorContext(ctx, "save error", "error", err) 223 236 } 224 237 // saveRecipesToUserProfile saves recipes to the user profile if they were marked as saved. 225 238 226 239 // Use the current user ID when saving recipes to the user profile 227 - if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil { 228 - slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err) 240 + // needs user to be logged in. Only do on finalize? 241 + if currentUser.ID != "" { 242 + if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil { 243 + slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err) 244 + } 229 245 } 230 246 }() 231 - // TODO should we just redirect to cache page here? 232 - // need to save params first and do spin in hash loop above. 233 - s.Spin(w, r) 247 + redirectToHash(w, r, hash) 234 248 } 235 249 func (s *server) Spin(w http.ResponseWriter, r *http.Request) { 236 250 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") ··· 249 263 slog.ErrorContext(ctx, "home template execute error", "error", err) 250 264 http.Error(w, "template error", http.StatusInternalServerError) 251 265 } 266 + } 267 + 268 + func redirectToHash(w http.ResponseWriter, r *http.Request, hash string) { 269 + http.Redirect(w, r, "/recipes?h="+hash, http.StatusSeeOther) 252 270 } 253 271 254 272 func (s *server) Wait() { ··· 305 323 return nil 306 324 } 307 325 308 - // move to admin? 309 - func (s *server) ingredients(ctx context.Context, w http.ResponseWriter, p *generatorParams) { 326 + // move to admin? Nah let the people see 327 + func (s *server) ingredients(w http.ResponseWriter, r *http.Request) { 328 + ctx := r.Context() 329 + loc := r.PathValue("location") 330 + l, err := s.locServer.GetLocationByID(ctx, loc) 331 + if err != nil { 332 + http.Error(w, "invalid location id", http.StatusBadRequest) 333 + return 334 + } 335 + // later use saved items 336 + p := DefaultParams(l, time.Now()) 337 + 310 338 lochash := p.LocationHash() 311 339 ingredientblob, err := s.cache.Get(ctx, lochash) 312 340 if err != nil {