···1111 "careme/internal/recipes"
1212 "careme/internal/users"
1313 "context"
1414+ "errors"
1415 "fmt"
1516 "log/slog"
1617 "os"
···130131 }
131132 p.LastRecipes = append(p.LastRecipes, last.Title)
132133 }
134134+ if err := rio.SaveParams(ctx, p); err != nil {
135135+ if errors.Is(err, recipes.AlreadyExists) {
136136+ slog.InfoContext(ctx, "params already exist, another process likely generated", "user", user.ID)
137137+ return
138138+ }
139139+140140+ slog.ErrorContext(ctx, "failed to save params", "error", err.Error())
141141+ return
142142+ }
133143134144 shoppingList, err := m.generator.GenerateRecipes(ctx, p)
135145 if err != nil {
136146 slog.ErrorContext(ctx, "failed to generate recipes for user", "user", user.Email)
137147 return
138148 }
139139- // coombine hee save recipes with html
140140- if err := rio.SaveShoppingList(ctx, shoppingList, p); err != nil {
149149+150150+ // combine hee save recipes with html
151151+ if err := rio.SaveShoppingList(ctx, shoppingList, p.Hash()); err != nil {
141152 slog.ErrorContext(ctx, "failed to save shopping list", "error", err.Error())
142153 return
143154 }
+1
internal/cache/file.go
···13131414type Cache interface {
1515 Get(ctx context.Context, key string) (io.ReadCloser, error)
1616+ //TODO define set behavior if it already exists. Maybe go immutable and error?
1617 Set(ctx context.Context, key, value string) error
1718 Exists(ctx context.Context, key string) (bool, error)
1819}
+4-32
internal/recipes/generator.go
···88 "careme/internal/locations"
99 "context"
1010 "encoding/json"
1111- "errors"
1211 "fmt"
1312 "log/slog"
1413 "net/http"
···2726}
28272928type Generator struct {
3030- config *config.Config
3131- aiClient aiClient
3232- krogerClient kroger.ClientWithResponsesInterface // probably need only subset
3333- cache cache.Cache
3434- inFlight map[string]struct{}
3535- generationLock sync.Mutex
2929+ config *config.Config
3030+ aiClient aiClient
3131+ krogerClient kroger.ClientWithResponsesInterface // probably need only subset
3232+ cache cache.Cache
3633}
37343835func NewGenerator(cfg *config.Config, cache cache.Cache) (generator, error) {
···4946 config: cfg,
5047 aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"),
5148 krogerClient: client,
5252- inFlight: make(map[string]struct{}),
5349 }, nil
5450}
55515656-// eventually we want to use blob with exipiry for this
5757-func (g *Generator) isGenerating(hash string) (bool, func()) {
5858- g.generationLock.Lock()
5959- defer g.generationLock.Unlock()
6060- if _, exists := g.inFlight[hash]; exists {
6161- return true, nil
6262- }
6363- g.inFlight[hash] = struct{}{}
6464- return false, func() {
6565- g.generationLock.Lock()
6666- defer g.generationLock.Unlock()
6767- delete(g.inFlight, hash)
6868- }
6969-}
7070-7171-var InProgress error = errors.New("generation in progress")
7272-7352func (g *Generator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) {
7453 hash := p.Hash()
7575- generating, done := g.isGenerating(hash)
7676- if generating {
7777- slog.InfoContext(ctx, "Generation already in progress, skipping", "hash", hash)
7878- return nil, InProgress
7979- }
8080- defer done()
8154 start := time.Now()
82558383- var err error
8456 if p.ConversationID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) {
8557 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID)
8658 // these should both always be true. Warn if not because its a caching bug?
+25-14
internal/recipes/io.go
···8383 return errors.Join(errs...)
8484}
85858686-func (rio *recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, p *generatorParams) error {
8787- // Save each recipe separately by its hash
8888- if err := rio.SaveRecipes(ctx, shoppingList.Recipes, p.Hash()); err != nil {
8686+var AlreadyExists = errors.New("already exists")
8787+8888+func (rio *recipeio) SaveParams(ctx context.Context, p *generatorParams) error {
8989+ //not atomic push this into cache
9090+ exists, err := rio.Cache.Exists(ctx, p.Hash()+".params")
9191+ if err != nil {
9292+ slog.ErrorContext(ctx, "failed to check existing params in cache", "location", p.String(), "error", err)
8993 return err
9094 }
9191- // we could actually nuke out the rest of recipe and lazily load but not yet
9292- shoppingJSON := lo.Must(json.Marshal(shoppingList))
9393- if err := rio.Cache.Set(ctx, p.Hash(), string(shoppingJSON)); err != nil {
9494- slog.ErrorContext(ctx, "failed to cache shopping list document", "location", p.String(), "error", err)
9595- return err
9595+9696+ if exists {
9797+ return AlreadyExists
9698 }
97999898- // Also cache the params for hash-based retrieval
9999- // TODO: Consider embedding the params directly in the shoppingList structure.
100100- // This would allow us to cache both the shopping list and its associated parameters together,
101101- // avoiding the need for a separate cache entry for params (currently stored as "<hash>.params").
102102- // Embedding params could simplify cache management and ensure all relevant data is retrieved together.
103103- // Persist the latest conversation IDs with the params so follow-ups can reuse them.
104100 paramsJSON := lo.Must(json.Marshal(p))
105101 if err := rio.Cache.Set(ctx, p.Hash()+".params", string(paramsJSON)); err != nil {
106102 slog.ErrorContext(ctx, "failed to cache params", "location", p.String(), "error", err)
···108104 }
109105 return nil
110106}
107107+108108+func (rio *recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, hash string) error {
109109+ // Save each recipe separately by its hash
110110+ if err := rio.SaveRecipes(ctx, shoppingList.Recipes, hash); err != nil {
111111+ return err
112112+ }
113113+ // we could actually nuke out the rest of recipe and lazily load but not yet
114114+ shoppingJSON := lo.Must(json.Marshal(shoppingList))
115115+ if err := rio.Cache.Set(ctx, hash, string(shoppingJSON)); err != nil {
116116+ slog.ErrorContext(ctx, "failed to cache shopping list document", "hash", hash, "error", err)
117117+ return err
118118+ }
119119+120120+ return nil
121121+}
+68-40
internal/recipes/server.go
···5656func (s *server) Register(mux *http.ServeMux) {
5757 mux.HandleFunc("GET /recipes", s.handleRecipes)
5858 mux.HandleFunc("GET /recipe/{hash}", s.handleSingle)
5959+ //maybe this should be under locations server?
6060+ mux.HandleFunc("GET /ingredients/{location}", s.ingredients)
6161+5962}
60636164func (s *server) handleSingle(w http.ResponseWriter, r *http.Request) {
···9710098101func (s *server) handleRecipes(w http.ResponseWriter, r *http.Request) {
99102 ctx := r.Context()
100100- currentUser, err := users.FromRequest(r, s.storage)
101101- if err != nil {
102102- if errors.Is(err, users.ErrNotFound) {
103103- users.ClearCookie(w)
104104- http.Redirect(w, r, "/", http.StatusSeeOther)
105105- return
106106- }
107107- slog.ErrorContext(ctx, "failed to load user for recipes", "error", err)
108108- http.Error(w, "unable to load account", http.StatusInternalServerError)
109109- return
110110- }
111111- if currentUser == nil {
112112- currentUser = &users.User{LastRecipes: []users.Recipe{}}
113113- }
114103115104 if hashParam := r.URL.Query().Get("h"); hashParam != "" {
116116- //TODO check if generating and spin.
117117- slist, err := s.FromCache(ctx, hashParam)
105105+106106+ slist, err := s.FromCache(ctx, hashParam) // ideally should memory cache this so lots of reloads don't constantly go out to azure
118107 if err != nil {
108108+ if errors.Is(err, cache.ErrNotFound) {
109109+ //how do we time this out and go try and regenerate
110110+ //should we put start time in params or a seperate blob
111111+ s.Spin(w, r)
112112+ return
113113+ }
119114 slog.ErrorContext(ctx, "failed to load recipe list for hash", "hash", hashParam, "error", err)
120115 http.Error(w, "recipe not found or expired", http.StatusNotFound)
121116 return
···144139 // what do we do with this?
145140 // p.UserID = currentUser.ID
146141147147- if r.URL.Query().Get("ingredients") == "true" {
148148- s.ingredients(ctx, w, p)
142142+ //if params are already saved redirect and assume someone kicks off genration
143143+144144+ currentUser, err := users.FromRequest(r, s.storage)
145145+ if err != nil {
146146+ if errors.Is(err, users.ErrNotFound) {
147147+ users.ClearCookie(w)
148148+ http.Redirect(w, r, "/", http.StatusSeeOther)
149149+ return
150150+ }
151151+ slog.ErrorContext(ctx, "failed to load user for recipes", "error", err)
152152+ http.Error(w, "unable to load account", http.StatusInternalServerError)
153153+ return
154154+ }
155155+ if currentUser == nil {
156156+ currentUser = &users.User{LastRecipes: []users.Recipe{}}
157157+ }
158158+159159+ if err := s.SaveParams(ctx, p); err != nil {
160160+ if errors.Is(err, AlreadyExists) {
161161+ slog.InfoContext(ctx, "params already existed redirecting", "hash", p.Hash())
162162+ redirectToHash(w, r, p.Hash())
163163+ return
164164+ }
165165+ slog.ErrorContext(ctx, "failed to save params", "error", err)
166166+ http.Error(w, "internal server error", http.StatusInternalServerError)
149167 return
150168 }
151169170170+ //After this failures lead to recipe orphaning.
171171+172172+ hash := p.Hash()
173173+152174 // Handle finalize - save recipes to user profile and display filtered list
153175 if r.URL.Query().Get("finalize") == "true" {
154176 // Check if user is authenticated
···177199 ConversationID: p.ConversationID,
178200 }
179201180180- // should finlize go into params to get a different hash that previous one with unsaved?
202202+ // should finalize go into params to get a different hash that previous one with unsaved?
181203 // or should we shove a guid or iteration in params along with conversation id. Response id?
182182- if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil {
204204+ if err := s.SaveShoppingList(ctx, shoppingList, hash); err != nil {
183205 slog.ErrorContext(ctx, "save error", "error", err)
206206+ http.Error(w, "failed to save finalized recipes", http.StatusInternalServerError)
207207+ return
184208 }
185185- http.Redirect(w, r, "/recipes?h="+p.Hash(), http.StatusSeeOther)
186186- return
187187- }
188188-189189- hash := p.Hash()
190190- if _, err := s.FromCache(ctx, hash); err == nil {
191191- // TODO check not found error explicitly
192192- http.Redirect(w, r, "/recipes?h="+p.Hash(), http.StatusSeeOther)
209209+ redirectToHash(w, r, hash)
193210 return
194211 }
195212···208225 slog.InfoContext(ctx, "generating cached recipes", "params", p.String(), "hash", hash)
209226 shoppingList, err := s.generator.GenerateRecipes(ctx, p)
210227 if err != nil {
211211- if errors.Is(err, InProgress) {
212212- slog.InfoContext(ctx, "generation already in progress, skipping save", "hash", hash)
213213- return
214214- }
215228 slog.ErrorContext(ctx, "generate error", "error", err)
216229 return
217230 }
218231219232 // add saved recipes here rather than each
220233221221- if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil {
234234+ if err := s.SaveShoppingList(ctx, shoppingList, hash); err != nil {
222235 slog.ErrorContext(ctx, "save error", "error", err)
223236 }
224237 // saveRecipesToUserProfile saves recipes to the user profile if they were marked as saved.
225238226239 // Use the current user ID when saving recipes to the user profile
227227- if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil {
228228- slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err)
240240+ // needs user to be logged in. Only do on finalize?
241241+ if currentUser.ID != "" {
242242+ if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil {
243243+ slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err)
244244+ }
229245 }
230246 }()
231231- // TODO should we just redirect to cache page here?
232232- // need to save params first and do spin in hash loop above.
233233- s.Spin(w, r)
247247+ redirectToHash(w, r, hash)
234248}
235249func (s *server) Spin(w http.ResponseWriter, r *http.Request) {
236250 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
···249263 slog.ErrorContext(ctx, "home template execute error", "error", err)
250264 http.Error(w, "template error", http.StatusInternalServerError)
251265 }
266266+}
267267+268268+func redirectToHash(w http.ResponseWriter, r *http.Request, hash string) {
269269+ http.Redirect(w, r, "/recipes?h="+hash, http.StatusSeeOther)
252270}
253271254272func (s *server) Wait() {
···305323 return nil
306324}
307325308308-// move to admin?
309309-func (s *server) ingredients(ctx context.Context, w http.ResponseWriter, p *generatorParams) {
326326+// move to admin? Nah let the people see
327327+func (s *server) ingredients(w http.ResponseWriter, r *http.Request) {
328328+ ctx := r.Context()
329329+ loc := r.PathValue("location")
330330+ l, err := s.locServer.GetLocationByID(ctx, loc)
331331+ if err != nil {
332332+ http.Error(w, "invalid location id", http.StatusBadRequest)
333333+ return
334334+ }
335335+ // later use saved items
336336+ p := DefaultParams(l, time.Now())
337337+310338 lochash := p.LocationHash()
311339 ingredientblob, err := s.cache.Get(ctx, lochash)
312340 if err != nil {