ai cooking
0
fork

Configure Feed

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

Merge pull request #122 from paulgmiller/generatorParams

Generator params

authored by

Paul Miller and committed by
GitHub
58d8606a db5fe981

+226 -208
+6 -3
cmd/careme/mail.go
··· 115 115 } 116 116 117 117 p := recipes.DefaultParams(l, time.Now().Add(-6*time.Hour)) // how do we get the timezone of the user? 118 - p.UserID = user.ID 118 + // p.UserID = user.ID 119 119 rio := recipes.IO(m.cache) 120 120 if _, err := rio.FromCache(ctx, p.Hash()); err == nil { 121 121 // already generated. Assume we sent for now (need better atomic tracking) ··· 136 136 slog.ErrorContext(ctx, "failed to generate recipes for user", "user", user.Email) 137 137 return 138 138 } 139 - // combine here save recipes with html 140 - rio.SaveShoppingList(ctx, shoppingList, p) 139 + // coombine hee save recipes with html 140 + if err := rio.SaveShoppingList(ctx, shoppingList, p); err != nil { 141 + slog.ErrorContext(ctx, "failed to save shopping list", "error", err.Error()) 142 + return 143 + } 141 144 142 145 var buf bytes.Buffer 143 146 recipes.FormatMail(p, *shoppingList, &buf)
-105
internal/recipes/generator.go
··· 7 7 "careme/internal/kroger" 8 8 "careme/internal/locations" 9 9 "context" 10 - "encoding/base64" 11 10 "encoding/json" 12 11 "errors" 13 12 "fmt" 14 - "hash/fnv" 15 - "io" 16 13 "log/slog" 17 14 "net/http" 18 15 "slices" ··· 20 17 "strings" 21 18 "sync" 22 19 "time" 23 - 24 - "github.com/samber/lo" 25 20 ) 26 21 27 22 type aiClient interface { ··· 68 63 g.generationLock.Lock() 69 64 defer g.generationLock.Unlock() 70 65 delete(g.inFlight, hash) 71 - } 72 - } 73 - 74 - type generatorParams struct { 75 - Location *locations.Location `json:"location,omitempty"` 76 - Date time.Time `json:"date,omitempty"` 77 - Staples []filter `json:"staples,omitempty"` 78 - // People int 79 - Instructions string `json:"instructions,omitempty"` 80 - LastRecipes []string `json:"last_recipes,omitempty"` 81 - UserID string `json:"user_id,omitempty"` 82 - ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in seperately to generate recipes? 83 - Saved []ai.Recipe `json:"saved_recipes,omitempty"` 84 - Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"` 85 - } 86 - 87 - func DefaultParams(l *locations.Location, date time.Time) *generatorParams { 88 - // normalize to midnight (shave hours, minutes, seconds, nanoseconds) 89 - date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) 90 - return &generatorParams{ 91 - Date: date, // shave time 92 - Location: l, 93 - // People: 2, 94 - Staples: DefaultStaples(), 95 - } 96 - } 97 - 98 - func (g *generatorParams) String() string { 99 - return fmt.Sprintf("%s on %s", g.Location.ID, g.Date.Format("2006-01-02")) 100 - } 101 - 102 - // Hash this is how we find shoppinglists and params 103 - // intentionally not including ConversationID to preserve old hashes 104 - func (g *generatorParams) Hash() string { 105 - fnv := fnv.New64a() 106 - lo.Must(io.WriteString(fnv, g.Location.ID)) 107 - lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 108 - bytes := lo.Must(json.Marshal(g.Staples)) 109 - lo.Must(fnv.Write(bytes)) 110 - lo.Must(io.WriteString(fnv, g.Instructions)) // rethink this? if they're all in convo should we have one id and ability to walk back? 111 - for _, saved := range g.Saved { 112 - lo.Must(io.WriteString(fnv, "saved"+saved.ComputeHash())) 113 - } 114 - for _, dismissed := range g.Dismissed { 115 - lo.Must(io.WriteString(fnv, "dismissed"+dismissed.ComputeHash())) 116 - } 117 - // this is actually a list not a recipe and isn't necessary. TODO figure out how to remove 118 - // could fix without breaking by doing two lookups? 119 - return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe"))) 120 - } 121 - 122 - // so far just excludes instructions. Can exclude people and other things 123 - func (g *generatorParams) LocationHash() string { 124 - fnv := fnv.New64a() 125 - lo.Must(io.WriteString(fnv, g.Location.ID)) 126 - lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 127 - bytes := lo.Must(json.Marshal(g.Staples)) // excited fro this to break in some wierd way 128 - lo.Must(fnv.Write(bytes)) 129 - // see comment above this suffix is unceessary but keeps old hashes working 130 - return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("ingredients"))) 131 - } 132 - 133 - func DefaultStaples() []filter { 134 - return []filter{ 135 - { 136 - Term: "beef", 137 - Brands: []string{"Simple Truth", "Kroger"}, 138 - }, 139 - { 140 - Term: "chicken", 141 - Brands: []string{"Foster Farms", "Draper Valley", "Simple Truth"}, //"Simple Truth"? do these vary in every state? 142 - }, 143 - { 144 - Term: "fish", 145 - }, 146 - { 147 - Term: "pork", // Kroger? 148 - Brands: []string{"PORK", "Kroger", "Harris Teeter"}, 149 - }, 150 - { 151 - Term: "shellfish", 152 - Brands: []string{"Sand Bar", "Kroger"}, 153 - Frozen: true, // remove after 500 sadness? 154 - }, 155 - { 156 - Term: "lamb", 157 - Brands: []string{"Simple Truth"}, 158 - }, 159 - { 160 - Term: "produce vegetable", 161 - Brands: []string{"*"}, // ther's alot of fresh * and kroger here. cut this down after 500 sadness 162 - }, 163 66 } 164 67 } 165 68 ··· 375 278 } 376 279 return *s 377 280 } 378 - 379 - // toFloat32 returns the float32 value if non-nil, or 0.0 otherwise. 380 - func toFloat32(f *float32) float32 { 381 - if f == nil { 382 - return 0.0 383 - } 384 - return *f 385 - }
+187
internal/recipes/params.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/ai" 5 + "careme/internal/cache" 6 + "careme/internal/locations" 7 + "context" 8 + "encoding/base64" 9 + "encoding/json" 10 + "errors" 11 + "fmt" 12 + "hash/fnv" 13 + "io" 14 + "log/slog" 15 + "net/http" 16 + "strings" 17 + "time" 18 + 19 + "github.com/samber/lo" 20 + ) 21 + 22 + type generatorParams struct { 23 + Location *locations.Location `json:"location,omitempty"` 24 + Date time.Time `json:"date,omitempty"` 25 + Staples []filter `json:"staples,omitempty"` 26 + // People int 27 + Instructions string `json:"instructions,omitempty"` 28 + LastRecipes []string `json:"last_recipes,omitempty"` 29 + // UserID string `json:"user_id,omitempty"` 30 + ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes? 31 + Saved []ai.Recipe `json:"saved_recipes,omitempty"` 32 + Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"` 33 + } 34 + 35 + func DefaultParams(l *locations.Location, date time.Time) *generatorParams { 36 + // normalize to midnight (shave hours, minutes, seconds, nanoseconds) 37 + date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) 38 + return &generatorParams{ 39 + Date: date, // shave time 40 + Location: l, 41 + // People: 2, 42 + Staples: DefaultStaples(), 43 + } 44 + } 45 + 46 + func (g *generatorParams) String() string { 47 + return fmt.Sprintf("%s on %s", g.Location.ID, g.Date.Format("2006-01-02")) 48 + } 49 + 50 + // Hash this is how we find shoppinglists and params 51 + // intentionally not including ConversationID to preserve old hashes 52 + func (g *generatorParams) Hash() string { 53 + fnv := fnv.New64a() 54 + lo.Must(io.WriteString(fnv, g.Location.ID)) 55 + lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 56 + bytes := lo.Must(json.Marshal(g.Staples)) 57 + lo.Must(fnv.Write(bytes)) 58 + lo.Must(io.WriteString(fnv, g.Instructions)) // rethink this? if they're all in convo should we have one id and ability to walk back? 59 + for _, saved := range g.Saved { 60 + lo.Must(io.WriteString(fnv, "saved"+saved.ComputeHash())) 61 + } 62 + for _, dismissed := range g.Dismissed { 63 + lo.Must(io.WriteString(fnv, "dismissed"+dismissed.ComputeHash())) 64 + } 65 + // this is actually a list not a recipe and isn't necessary. TODO figure out how to remove 66 + // could fix without breaking by doing two lookups? 67 + return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe"))) 68 + } 69 + 70 + // so far just excludes instructions. Can exclude people and other things 71 + func (g *generatorParams) LocationHash() string { 72 + fnv := fnv.New64a() 73 + lo.Must(io.WriteString(fnv, g.Location.ID)) 74 + lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02"))) 75 + bytes := lo.Must(json.Marshal(g.Staples)) // excited fro this to break in some weird way 76 + lo.Must(fnv.Write(bytes)) 77 + // see comment above this suffix is unceessary but keeps old hashes working 78 + return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("ingredients"))) 79 + } 80 + 81 + // loadParamsFromHash loads generator params from cache using the hash 82 + func loadParamsFromHash(ctx context.Context, hash string, c cache.Cache) (*generatorParams, error) { 83 + paramsReader, err := c.Get(ctx, hash+".params") 84 + if err != nil { 85 + return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 86 + } 87 + defer paramsReader.Close() 88 + 89 + var params generatorParams 90 + if err := json.NewDecoder(paramsReader).Decode(&params); err != nil { 91 + return nil, fmt.Errorf("failed to decode params: %w", err) 92 + } 93 + return &params, nil 94 + } 95 + 96 + func (s *server) ParseQueryArgs(ctx context.Context, r *http.Request) (*generatorParams, error) { 97 + loc := r.URL.Query().Get("location") 98 + if loc == "" { 99 + return nil, errors.New("must provide location id") 100 + } 101 + 102 + l, err := s.locServer.GetLocationByID(ctx, loc) 103 + if err != nil { 104 + return nil, err 105 + } 106 + 107 + dateStr := r.URL.Query().Get("date") 108 + if dateStr == "" { 109 + dateStr = time.Now().Format("2006-01-02") 110 + } 111 + date, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC) 112 + if err != nil { 113 + return nil, err 114 + } 115 + 116 + p := DefaultParams(l, date) 117 + p.Instructions = r.URL.Query().Get("instructions") 118 + 119 + // Handle saved and dismissed recipe hashes from checkboxes 120 + // Query().Get returns first value, Query() returns all values 121 + // will be empty values for every recipe and two for ones with no action 122 + // TODO look at way not to duplicate so many query arguments and pass down just a saved list or a query arg for each saved item. 123 + clean := func(s string, _ int) (string, bool) { 124 + ts := strings.TrimSpace(s) 125 + return ts, ts != "" 126 + } 127 + savedHashes := lo.FilterMap(r.URL.Query()["saved"], clean) 128 + dismissedHashes := lo.FilterMap(r.URL.Query()["dismissed"], clean) 129 + // Load saved recipes from cache by their hashes 130 + for _, hash := range savedHashes { 131 + recipe, err := s.SingleFromCache(ctx, hash) 132 + if err != nil { 133 + slog.ErrorContext(ctx, "failed to load saved recipe by hash", "hash", hash, "error", err) 134 + continue 135 + } 136 + slog.InfoContext(ctx, "adding saved recipe to params", "title", recipe.Title, "hash", hash) 137 + p.Saved = append(p.Saved, *recipe) 138 + } 139 + 140 + // Add dismissed recipe titles to instructions so AI knows what to avoid 141 + for _, hash := range dismissedHashes { 142 + recipe, err := s.SingleFromCache(ctx, hash) 143 + if err != nil { 144 + slog.ErrorContext(ctx, "failed to load dismissed recipe by hash", "hash", hash, "error", err) 145 + continue 146 + } 147 + slog.InfoContext(ctx, "adding dismissed recipe to params", "title", recipe.Title, "hash", hash) 148 + p.Dismissed = append(p.Dismissed, *recipe) 149 + } 150 + // should this be in hash? 151 + p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 152 + 153 + return p, nil 154 + } 155 + 156 + func DefaultStaples() []filter { 157 + return []filter{ 158 + { 159 + Term: "beef", 160 + Brands: []string{"Simple Truth", "Kroger"}, 161 + }, 162 + { 163 + Term: "chicken", 164 + Brands: []string{"Foster Farms", "Draper Valley", "Simple Truth"}, //"Simple Truth"? do these vary in every state? 165 + }, 166 + { 167 + Term: "fish", 168 + }, 169 + { 170 + Term: "pork", // Kroger? 171 + Brands: []string{"PORK", "Kroger", "Harris Teeter"}, 172 + }, 173 + { 174 + Term: "shellfish", 175 + Brands: []string{"Sand Bar", "Kroger"}, 176 + Frozen: true, // remove after 500 sadness? 177 + }, 178 + { 179 + Term: "lamb", 180 + Brands: []string{"Simple Truth"}, 181 + }, 182 + { 183 + Term: "produce vegetable", 184 + Brands: []string{"*"}, // ther's alot of fresh * and kroger here. cut this down after 500 sadness 185 + }, 186 + } 187 + }
+33 -100
internal/recipes/server.go
··· 16 16 "html/template" 17 17 "log/slog" 18 18 "net/http" 19 - "strings" 20 19 "sync" 21 20 "time" 22 21 ··· 78 77 Name: "Unknown Location", 79 78 }, time.Now()) 80 79 if recipe.OriginHash != "" { 81 - loadedp, err := s.loadParamsFromHash(ctx, recipe.OriginHash) 80 + loadedp, err := loadParamsFromHash(ctx, recipe.OriginHash, s.cache) 82 81 if err != nil { 83 82 slog.ErrorContext(ctx, "failed to load params for hash", "hash", recipe.OriginHash, "error", err) 84 83 // http.Error(w, "recipe not found or expired", http.StatusNotFound) ··· 120 119 http.Error(w, "recipe not found or expired", http.StatusNotFound) 121 120 return 122 121 } 123 - p, err := s.loadParamsFromHash(ctx, hashParam) 122 + p, err := loadParamsFromHash(ctx, hashParam, s.cache) 124 123 if err != nil { 125 124 slog.ErrorContext(ctx, "failed to load params for hash", "hash", hashParam, "error", err) 126 125 p = DefaultParams(&locations.Location{ ··· 129 128 }, time.Now()) 130 129 } 131 130 FormatChatHTML(p, *slist, w) 131 + // backfill 132 132 go func() { 133 133 cutoff := lo.Must(time.Parse(time.DateOnly, "2025-12-22")) 134 134 if p.Date.After(cutoff) { ··· 142 142 return 143 143 } 144 144 145 - loc := r.URL.Query().Get("location") 146 - if loc == "" { 147 - w.WriteHeader(http.StatusOK) 148 - _, _ = w.Write([]byte("specify a location id to generate recipes")) 149 - return 150 - } 151 - 152 - dateStr := r.URL.Query().Get("date") 153 - if dateStr == "" { 154 - http.Redirect(w, r, "/recipes?location="+loc+"&date="+time.Now().Format("2006-01-02"), http.StatusSeeOther) 155 - return 156 - } 157 - 158 - date, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC) 145 + p, err := s.ParseQueryArgs(ctx, r) 159 146 if err != nil { 160 - http.Error(w, "invalid date format, use YYYY-MM-DD", http.StatusBadRequest) 147 + http.Error(w, fmt.Sprintf("invalid query parameters: %v", err), http.StatusBadRequest) 161 148 return 162 149 } 163 - 164 - l, err := s.locServer.GetLocationByID(ctx, loc) 165 - if err != nil { 166 - http.Error(w, "could not get location details", http.StatusBadRequest) 167 - return 168 - } 169 - 170 - p := DefaultParams(l, date) 171 - 172 - p.UserID = currentUser.ID 150 + // what do we do with this? 151 + // p.UserID = currentUser.ID 173 152 174 153 if r.URL.Query().Get("ingredients") == "true" { 175 - lochash := p.LocationHash() 176 - ingredientblob, err := s.cache.Get(ctx, lochash) 177 - if err != nil { 178 - http.Error(w, "ingredients not found in cache", http.StatusNotFound) 179 - return 180 - } 181 - slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash) 182 - defer ingredientblob.Close() 183 - dec := json.NewDecoder(ingredientblob) 184 - var ingredients []kroger.Ingredient 185 - err = dec.Decode(&ingredients) 186 - if err != nil { 187 - http.Error(w, "failed to decode ingredients", http.StatusInternalServerError) 188 - return 189 - } 190 - enc := json.NewEncoder(w) 191 - enc.SetIndent("", " ") 192 - if err := enc.Encode(ingredients); err != nil { 193 - http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 194 - return 195 - } 196 - // make this a html thats readable. 197 - w.Header().Add("Content-Type", "application/json") 154 + s.ingredients(ctx, w, p) 198 155 return 199 156 } 200 157 ··· 205 162 p.LastRecipes = append(p.LastRecipes, last.Title) 206 163 } 207 164 208 - if instructions := r.URL.Query().Get("instructions"); instructions != "" { 209 - p.Instructions = instructions 210 - } 211 - 212 - // Handle saved and dismissed recipe hashes from checkboxes 213 - // Query().Get returns first value, Query() returns all values 214 - // will be empty values for every recipe and two for ones with no action 215 - // TODO look at way not to duplicate so many query arguments and pass down just a saved list or a query arg for each saved item. 216 - clean := func(s string, _ int) (string, bool) { 217 - ts := strings.TrimSpace(s) 218 - return ts, ts != "" 219 - } 220 - savedHashes := lo.FilterMap(r.URL.Query()["saved"], clean) 221 - dismissedHashes := lo.FilterMap(r.URL.Query()["dismissed"], clean) 222 - // Load saved recipes from cache by their hashes 223 - for _, hash := range savedHashes { 224 - recipe, err := s.SingleFromCache(ctx, hash) 225 - if err != nil { 226 - slog.ErrorContext(ctx, "failed to load saved recipe by hash", "hash", hash, "error", err) 227 - continue 228 - } 229 - slog.InfoContext(ctx, "adding saved recipe to params", "title", recipe.Title, "hash", hash) 230 - p.Saved = append(p.Saved, *recipe) 231 - } 232 - 233 - // Add dismissed recipe titles to instructions so AI knows what to avoid 234 - for _, hash := range dismissedHashes { 235 - recipe, err := s.SingleFromCache(ctx, hash) 236 - if err != nil { 237 - slog.ErrorContext(ctx, "failed to load dismissed recipe by hash", "hash", hash, "error", err) 238 - continue 239 - } 240 - slog.InfoContext(ctx, "adding dismissed recipe to params", "title", recipe.Title, "hash", hash) 241 - p.Dismissed = append(p.Dismissed, *recipe) 242 - } 243 - 244 165 hash := p.Hash() 245 166 if list, err := s.FromCache(ctx, hash); err == nil { 246 167 // TODO check not found error explicitly ··· 263 184 return 264 185 } 265 186 266 - // should this be in hash? 267 - p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 268 - 269 187 s.wg.Add(1) 270 188 go func() { 271 189 defer s.wg.Done() ··· 281 199 slog.ErrorContext(ctx, "generate error", "error", err) 282 200 return 283 201 } 202 + 203 + // add saved recipes here rather than each 204 + 284 205 if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil { 285 206 slog.ErrorContext(ctx, "save error", "error", err) 286 207 } ··· 363 284 return nil 364 285 } 365 286 366 - // loadParamsFromHash loads generator params from cache using the hash 367 - func (s *server) loadParamsFromHash(ctx context.Context, hash string) (*generatorParams, error) { 368 - paramsReader, err := s.cache.Get(ctx, hash+".params") 287 + // move to admin? 288 + func (s *server) ingredients(ctx context.Context, w http.ResponseWriter, p *generatorParams) { 289 + lochash := p.LocationHash() 290 + ingredientblob, err := s.cache.Get(ctx, lochash) 369 291 if err != nil { 370 - return nil, fmt.Errorf("params not found for hash %s: %w", hash, err) 292 + http.Error(w, "ingredients not found in cache", http.StatusNotFound) 293 + return 371 294 } 372 - defer paramsReader.Close() 373 - 374 - var params generatorParams 375 - if err := json.NewDecoder(paramsReader).Decode(&params); err != nil { 376 - return nil, fmt.Errorf("failed to decode params: %w", err) 295 + slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash) 296 + defer ingredientblob.Close() 297 + dec := json.NewDecoder(ingredientblob) 298 + var ingredients []kroger.Ingredient 299 + err = dec.Decode(&ingredients) 300 + if err != nil { 301 + http.Error(w, "failed to decode ingredients", http.StatusInternalServerError) 302 + return 377 303 } 378 - return &params, nil 304 + // make this a html thats readable. 305 + w.Header().Add("Content-Type", "application/json") 306 + enc := json.NewEncoder(w) 307 + enc.SetIndent("", " ") 308 + if err := enc.Encode(ingredients); err != nil { 309 + http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 310 + return 311 + } 379 312 }