···115115 }
116116117117 p := recipes.DefaultParams(l, time.Now().Add(-6*time.Hour)) // how do we get the timezone of the user?
118118- p.UserID = user.ID
118118+ // p.UserID = user.ID
119119 rio := recipes.IO(m.cache)
120120 if _, err := rio.FromCache(ctx, p.Hash()); err == nil {
121121 // already generated. Assume we sent for now (need better atomic tracking)
···136136 slog.ErrorContext(ctx, "failed to generate recipes for user", "user", user.Email)
137137 return
138138 }
139139- // combine here save recipes with html
140140- rio.SaveShoppingList(ctx, shoppingList, p)
139139+ // coombine hee save recipes with html
140140+ if err := rio.SaveShoppingList(ctx, shoppingList, p); err != nil {
141141+ slog.ErrorContext(ctx, "failed to save shopping list", "error", err.Error())
142142+ return
143143+ }
141144142145 var buf bytes.Buffer
143146 recipes.FormatMail(p, *shoppingList, &buf)
-105
internal/recipes/generator.go
···77 "careme/internal/kroger"
88 "careme/internal/locations"
99 "context"
1010- "encoding/base64"
1110 "encoding/json"
1211 "errors"
1312 "fmt"
1414- "hash/fnv"
1515- "io"
1613 "log/slog"
1714 "net/http"
1815 "slices"
···2017 "strings"
2118 "sync"
2219 "time"
2323-2424- "github.com/samber/lo"
2520)
26212722type aiClient interface {
···6863 g.generationLock.Lock()
6964 defer g.generationLock.Unlock()
7065 delete(g.inFlight, hash)
7171- }
7272-}
7373-7474-type generatorParams struct {
7575- Location *locations.Location `json:"location,omitempty"`
7676- Date time.Time `json:"date,omitempty"`
7777- Staples []filter `json:"staples,omitempty"`
7878- // People int
7979- Instructions string `json:"instructions,omitempty"`
8080- LastRecipes []string `json:"last_recipes,omitempty"`
8181- UserID string `json:"user_id,omitempty"`
8282- ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in seperately to generate recipes?
8383- Saved []ai.Recipe `json:"saved_recipes,omitempty"`
8484- Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"`
8585-}
8686-8787-func DefaultParams(l *locations.Location, date time.Time) *generatorParams {
8888- // normalize to midnight (shave hours, minutes, seconds, nanoseconds)
8989- date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
9090- return &generatorParams{
9191- Date: date, // shave time
9292- Location: l,
9393- // People: 2,
9494- Staples: DefaultStaples(),
9595- }
9696-}
9797-9898-func (g *generatorParams) String() string {
9999- return fmt.Sprintf("%s on %s", g.Location.ID, g.Date.Format("2006-01-02"))
100100-}
101101-102102-// Hash this is how we find shoppinglists and params
103103-// intentionally not including ConversationID to preserve old hashes
104104-func (g *generatorParams) Hash() string {
105105- fnv := fnv.New64a()
106106- lo.Must(io.WriteString(fnv, g.Location.ID))
107107- lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02")))
108108- bytes := lo.Must(json.Marshal(g.Staples))
109109- lo.Must(fnv.Write(bytes))
110110- 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?
111111- for _, saved := range g.Saved {
112112- lo.Must(io.WriteString(fnv, "saved"+saved.ComputeHash()))
113113- }
114114- for _, dismissed := range g.Dismissed {
115115- lo.Must(io.WriteString(fnv, "dismissed"+dismissed.ComputeHash()))
116116- }
117117- // this is actually a list not a recipe and isn't necessary. TODO figure out how to remove
118118- // could fix without breaking by doing two lookups?
119119- return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe")))
120120-}
121121-122122-// so far just excludes instructions. Can exclude people and other things
123123-func (g *generatorParams) LocationHash() string {
124124- fnv := fnv.New64a()
125125- lo.Must(io.WriteString(fnv, g.Location.ID))
126126- lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02")))
127127- bytes := lo.Must(json.Marshal(g.Staples)) // excited fro this to break in some wierd way
128128- lo.Must(fnv.Write(bytes))
129129- // see comment above this suffix is unceessary but keeps old hashes working
130130- return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("ingredients")))
131131-}
132132-133133-func DefaultStaples() []filter {
134134- return []filter{
135135- {
136136- Term: "beef",
137137- Brands: []string{"Simple Truth", "Kroger"},
138138- },
139139- {
140140- Term: "chicken",
141141- Brands: []string{"Foster Farms", "Draper Valley", "Simple Truth"}, //"Simple Truth"? do these vary in every state?
142142- },
143143- {
144144- Term: "fish",
145145- },
146146- {
147147- Term: "pork", // Kroger?
148148- Brands: []string{"PORK", "Kroger", "Harris Teeter"},
149149- },
150150- {
151151- Term: "shellfish",
152152- Brands: []string{"Sand Bar", "Kroger"},
153153- Frozen: true, // remove after 500 sadness?
154154- },
155155- {
156156- Term: "lamb",
157157- Brands: []string{"Simple Truth"},
158158- },
159159- {
160160- Term: "produce vegetable",
161161- Brands: []string{"*"}, // ther's alot of fresh * and kroger here. cut this down after 500 sadness
162162- },
16366 }
16467}
16568···375278 }
376279 return *s
377280}
378378-379379-// toFloat32 returns the float32 value if non-nil, or 0.0 otherwise.
380380-func toFloat32(f *float32) float32 {
381381- if f == nil {
382382- return 0.0
383383- }
384384- return *f
385385-}
+187
internal/recipes/params.go
···11+package recipes
22+33+import (
44+ "careme/internal/ai"
55+ "careme/internal/cache"
66+ "careme/internal/locations"
77+ "context"
88+ "encoding/base64"
99+ "encoding/json"
1010+ "errors"
1111+ "fmt"
1212+ "hash/fnv"
1313+ "io"
1414+ "log/slog"
1515+ "net/http"
1616+ "strings"
1717+ "time"
1818+1919+ "github.com/samber/lo"
2020+)
2121+2222+type generatorParams struct {
2323+ Location *locations.Location `json:"location,omitempty"`
2424+ Date time.Time `json:"date,omitempty"`
2525+ Staples []filter `json:"staples,omitempty"`
2626+ // People int
2727+ Instructions string `json:"instructions,omitempty"`
2828+ LastRecipes []string `json:"last_recipes,omitempty"`
2929+ // UserID string `json:"user_id,omitempty"`
3030+ ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes?
3131+ Saved []ai.Recipe `json:"saved_recipes,omitempty"`
3232+ Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"`
3333+}
3434+3535+func DefaultParams(l *locations.Location, date time.Time) *generatorParams {
3636+ // normalize to midnight (shave hours, minutes, seconds, nanoseconds)
3737+ date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
3838+ return &generatorParams{
3939+ Date: date, // shave time
4040+ Location: l,
4141+ // People: 2,
4242+ Staples: DefaultStaples(),
4343+ }
4444+}
4545+4646+func (g *generatorParams) String() string {
4747+ return fmt.Sprintf("%s on %s", g.Location.ID, g.Date.Format("2006-01-02"))
4848+}
4949+5050+// Hash this is how we find shoppinglists and params
5151+// intentionally not including ConversationID to preserve old hashes
5252+func (g *generatorParams) Hash() string {
5353+ fnv := fnv.New64a()
5454+ lo.Must(io.WriteString(fnv, g.Location.ID))
5555+ lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02")))
5656+ bytes := lo.Must(json.Marshal(g.Staples))
5757+ lo.Must(fnv.Write(bytes))
5858+ 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?
5959+ for _, saved := range g.Saved {
6060+ lo.Must(io.WriteString(fnv, "saved"+saved.ComputeHash()))
6161+ }
6262+ for _, dismissed := range g.Dismissed {
6363+ lo.Must(io.WriteString(fnv, "dismissed"+dismissed.ComputeHash()))
6464+ }
6565+ // this is actually a list not a recipe and isn't necessary. TODO figure out how to remove
6666+ // could fix without breaking by doing two lookups?
6767+ return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe")))
6868+}
6969+7070+// so far just excludes instructions. Can exclude people and other things
7171+func (g *generatorParams) LocationHash() string {
7272+ fnv := fnv.New64a()
7373+ lo.Must(io.WriteString(fnv, g.Location.ID))
7474+ lo.Must(io.WriteString(fnv, g.Date.Format("2006-01-02")))
7575+ bytes := lo.Must(json.Marshal(g.Staples)) // excited fro this to break in some weird way
7676+ lo.Must(fnv.Write(bytes))
7777+ // see comment above this suffix is unceessary but keeps old hashes working
7878+ return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("ingredients")))
7979+}
8080+8181+// loadParamsFromHash loads generator params from cache using the hash
8282+func loadParamsFromHash(ctx context.Context, hash string, c cache.Cache) (*generatorParams, error) {
8383+ paramsReader, err := c.Get(ctx, hash+".params")
8484+ if err != nil {
8585+ return nil, fmt.Errorf("params not found for hash %s: %w", hash, err)
8686+ }
8787+ defer paramsReader.Close()
8888+8989+ var params generatorParams
9090+ if err := json.NewDecoder(paramsReader).Decode(¶ms); err != nil {
9191+ return nil, fmt.Errorf("failed to decode params: %w", err)
9292+ }
9393+ return ¶ms, nil
9494+}
9595+9696+func (s *server) ParseQueryArgs(ctx context.Context, r *http.Request) (*generatorParams, error) {
9797+ loc := r.URL.Query().Get("location")
9898+ if loc == "" {
9999+ return nil, errors.New("must provide location id")
100100+ }
101101+102102+ l, err := s.locServer.GetLocationByID(ctx, loc)
103103+ if err != nil {
104104+ return nil, err
105105+ }
106106+107107+ dateStr := r.URL.Query().Get("date")
108108+ if dateStr == "" {
109109+ dateStr = time.Now().Format("2006-01-02")
110110+ }
111111+ date, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC)
112112+ if err != nil {
113113+ return nil, err
114114+ }
115115+116116+ p := DefaultParams(l, date)
117117+ p.Instructions = r.URL.Query().Get("instructions")
118118+119119+ // Handle saved and dismissed recipe hashes from checkboxes
120120+ // Query().Get returns first value, Query() returns all values
121121+ // will be empty values for every recipe and two for ones with no action
122122+ // 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.
123123+ clean := func(s string, _ int) (string, bool) {
124124+ ts := strings.TrimSpace(s)
125125+ return ts, ts != ""
126126+ }
127127+ savedHashes := lo.FilterMap(r.URL.Query()["saved"], clean)
128128+ dismissedHashes := lo.FilterMap(r.URL.Query()["dismissed"], clean)
129129+ // Load saved recipes from cache by their hashes
130130+ for _, hash := range savedHashes {
131131+ recipe, err := s.SingleFromCache(ctx, hash)
132132+ if err != nil {
133133+ slog.ErrorContext(ctx, "failed to load saved recipe by hash", "hash", hash, "error", err)
134134+ continue
135135+ }
136136+ slog.InfoContext(ctx, "adding saved recipe to params", "title", recipe.Title, "hash", hash)
137137+ p.Saved = append(p.Saved, *recipe)
138138+ }
139139+140140+ // Add dismissed recipe titles to instructions so AI knows what to avoid
141141+ for _, hash := range dismissedHashes {
142142+ recipe, err := s.SingleFromCache(ctx, hash)
143143+ if err != nil {
144144+ slog.ErrorContext(ctx, "failed to load dismissed recipe by hash", "hash", hash, "error", err)
145145+ continue
146146+ }
147147+ slog.InfoContext(ctx, "adding dismissed recipe to params", "title", recipe.Title, "hash", hash)
148148+ p.Dismissed = append(p.Dismissed, *recipe)
149149+ }
150150+ // should this be in hash?
151151+ p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id"))
152152+153153+ return p, nil
154154+}
155155+156156+func DefaultStaples() []filter {
157157+ return []filter{
158158+ {
159159+ Term: "beef",
160160+ Brands: []string{"Simple Truth", "Kroger"},
161161+ },
162162+ {
163163+ Term: "chicken",
164164+ Brands: []string{"Foster Farms", "Draper Valley", "Simple Truth"}, //"Simple Truth"? do these vary in every state?
165165+ },
166166+ {
167167+ Term: "fish",
168168+ },
169169+ {
170170+ Term: "pork", // Kroger?
171171+ Brands: []string{"PORK", "Kroger", "Harris Teeter"},
172172+ },
173173+ {
174174+ Term: "shellfish",
175175+ Brands: []string{"Sand Bar", "Kroger"},
176176+ Frozen: true, // remove after 500 sadness?
177177+ },
178178+ {
179179+ Term: "lamb",
180180+ Brands: []string{"Simple Truth"},
181181+ },
182182+ {
183183+ Term: "produce vegetable",
184184+ Brands: []string{"*"}, // ther's alot of fresh * and kroger here. cut this down after 500 sadness
185185+ },
186186+ }
187187+}
+33-100
internal/recipes/server.go
···1616 "html/template"
1717 "log/slog"
1818 "net/http"
1919- "strings"
2019 "sync"
2120 "time"
2221···7877 Name: "Unknown Location",
7978 }, time.Now())
8079 if recipe.OriginHash != "" {
8181- loadedp, err := s.loadParamsFromHash(ctx, recipe.OriginHash)
8080+ loadedp, err := loadParamsFromHash(ctx, recipe.OriginHash, s.cache)
8281 if err != nil {
8382 slog.ErrorContext(ctx, "failed to load params for hash", "hash", recipe.OriginHash, "error", err)
8483 // http.Error(w, "recipe not found or expired", http.StatusNotFound)
···120119 http.Error(w, "recipe not found or expired", http.StatusNotFound)
121120 return
122121 }
123123- p, err := s.loadParamsFromHash(ctx, hashParam)
122122+ p, err := loadParamsFromHash(ctx, hashParam, s.cache)
124123 if err != nil {
125124 slog.ErrorContext(ctx, "failed to load params for hash", "hash", hashParam, "error", err)
126125 p = DefaultParams(&locations.Location{
···129128 }, time.Now())
130129 }
131130 FormatChatHTML(p, *slist, w)
131131+ // backfill
132132 go func() {
133133 cutoff := lo.Must(time.Parse(time.DateOnly, "2025-12-22"))
134134 if p.Date.After(cutoff) {
···142142 return
143143 }
144144145145- loc := r.URL.Query().Get("location")
146146- if loc == "" {
147147- w.WriteHeader(http.StatusOK)
148148- _, _ = w.Write([]byte("specify a location id to generate recipes"))
149149- return
150150- }
151151-152152- dateStr := r.URL.Query().Get("date")
153153- if dateStr == "" {
154154- http.Redirect(w, r, "/recipes?location="+loc+"&date="+time.Now().Format("2006-01-02"), http.StatusSeeOther)
155155- return
156156- }
157157-158158- date, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC)
145145+ p, err := s.ParseQueryArgs(ctx, r)
159146 if err != nil {
160160- http.Error(w, "invalid date format, use YYYY-MM-DD", http.StatusBadRequest)
147147+ http.Error(w, fmt.Sprintf("invalid query parameters: %v", err), http.StatusBadRequest)
161148 return
162149 }
163163-164164- l, err := s.locServer.GetLocationByID(ctx, loc)
165165- if err != nil {
166166- http.Error(w, "could not get location details", http.StatusBadRequest)
167167- return
168168- }
169169-170170- p := DefaultParams(l, date)
171171-172172- p.UserID = currentUser.ID
150150+ // what do we do with this?
151151+ // p.UserID = currentUser.ID
173152174153 if r.URL.Query().Get("ingredients") == "true" {
175175- lochash := p.LocationHash()
176176- ingredientblob, err := s.cache.Get(ctx, lochash)
177177- if err != nil {
178178- http.Error(w, "ingredients not found in cache", http.StatusNotFound)
179179- return
180180- }
181181- slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash)
182182- defer ingredientblob.Close()
183183- dec := json.NewDecoder(ingredientblob)
184184- var ingredients []kroger.Ingredient
185185- err = dec.Decode(&ingredients)
186186- if err != nil {
187187- http.Error(w, "failed to decode ingredients", http.StatusInternalServerError)
188188- return
189189- }
190190- enc := json.NewEncoder(w)
191191- enc.SetIndent("", " ")
192192- if err := enc.Encode(ingredients); err != nil {
193193- http.Error(w, "failed to encode ingredients", http.StatusInternalServerError)
194194- return
195195- }
196196- // make this a html thats readable.
197197- w.Header().Add("Content-Type", "application/json")
154154+ s.ingredients(ctx, w, p)
198155 return
199156 }
200157···205162 p.LastRecipes = append(p.LastRecipes, last.Title)
206163 }
207164208208- if instructions := r.URL.Query().Get("instructions"); instructions != "" {
209209- p.Instructions = instructions
210210- }
211211-212212- // Handle saved and dismissed recipe hashes from checkboxes
213213- // Query().Get returns first value, Query() returns all values
214214- // will be empty values for every recipe and two for ones with no action
215215- // 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.
216216- clean := func(s string, _ int) (string, bool) {
217217- ts := strings.TrimSpace(s)
218218- return ts, ts != ""
219219- }
220220- savedHashes := lo.FilterMap(r.URL.Query()["saved"], clean)
221221- dismissedHashes := lo.FilterMap(r.URL.Query()["dismissed"], clean)
222222- // Load saved recipes from cache by their hashes
223223- for _, hash := range savedHashes {
224224- recipe, err := s.SingleFromCache(ctx, hash)
225225- if err != nil {
226226- slog.ErrorContext(ctx, "failed to load saved recipe by hash", "hash", hash, "error", err)
227227- continue
228228- }
229229- slog.InfoContext(ctx, "adding saved recipe to params", "title", recipe.Title, "hash", hash)
230230- p.Saved = append(p.Saved, *recipe)
231231- }
232232-233233- // Add dismissed recipe titles to instructions so AI knows what to avoid
234234- for _, hash := range dismissedHashes {
235235- recipe, err := s.SingleFromCache(ctx, hash)
236236- if err != nil {
237237- slog.ErrorContext(ctx, "failed to load dismissed recipe by hash", "hash", hash, "error", err)
238238- continue
239239- }
240240- slog.InfoContext(ctx, "adding dismissed recipe to params", "title", recipe.Title, "hash", hash)
241241- p.Dismissed = append(p.Dismissed, *recipe)
242242- }
243243-244165 hash := p.Hash()
245166 if list, err := s.FromCache(ctx, hash); err == nil {
246167 // TODO check not found error explicitly
···263184 return
264185 }
265186266266- // should this be in hash?
267267- p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id"))
268268-269187 s.wg.Add(1)
270188 go func() {
271189 defer s.wg.Done()
···281199 slog.ErrorContext(ctx, "generate error", "error", err)
282200 return
283201 }
202202+203203+ // add saved recipes here rather than each
204204+284205 if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil {
285206 slog.ErrorContext(ctx, "save error", "error", err)
286207 }
···363284 return nil
364285}
365286366366-// loadParamsFromHash loads generator params from cache using the hash
367367-func (s *server) loadParamsFromHash(ctx context.Context, hash string) (*generatorParams, error) {
368368- paramsReader, err := s.cache.Get(ctx, hash+".params")
287287+// move to admin?
288288+func (s *server) ingredients(ctx context.Context, w http.ResponseWriter, p *generatorParams) {
289289+ lochash := p.LocationHash()
290290+ ingredientblob, err := s.cache.Get(ctx, lochash)
369291 if err != nil {
370370- return nil, fmt.Errorf("params not found for hash %s: %w", hash, err)
292292+ http.Error(w, "ingredients not found in cache", http.StatusNotFound)
293293+ return
371294 }
372372- defer paramsReader.Close()
373373-374374- var params generatorParams
375375- if err := json.NewDecoder(paramsReader).Decode(¶ms); err != nil {
376376- return nil, fmt.Errorf("failed to decode params: %w", err)
295295+ slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash)
296296+ defer ingredientblob.Close()
297297+ dec := json.NewDecoder(ingredientblob)
298298+ var ingredients []kroger.Ingredient
299299+ err = dec.Decode(&ingredients)
300300+ if err != nil {
301301+ http.Error(w, "failed to decode ingredients", http.StatusInternalServerError)
302302+ return
377303 }
378378- return ¶ms, nil
304304+ // make this a html thats readable.
305305+ w.Header().Add("Content-Type", "application/json")
306306+ enc := json.NewEncoder(w)
307307+ enc.SetIndent("", " ")
308308+ if err := enc.Encode(ingredients); err != nil {
309309+ http.Error(w, "failed to encode ingredients", http.StatusInternalServerError)
310310+ return
311311+ }
379312}