ai cooking
0
fork

Configure Feed

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

Wine caching (#310)

* use a hash for wine key and pass in date

* save it

* as parallel helper

* even more efficient

* ingredient io

* few tweaks

* take interfaces

* conflicts bad

* documenation

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
aa5c9aa3 6b76a26e

+435 -85
+3
AGENTS.md
··· 8 8 - `recipes/`: Local output directory created at runtime; keep it out of commits unless intentionally adding fixtures. 9 9 - `internal/auth` : mostly clerk authorization 10 10 11 + ## Cache Layout 12 + - Cache key/prefix docs live in `docs/cache-layout.md`. Keep that file updated when cache keys are added or changed. 13 + 11 14 ## Build, Test, and Development Commands 12 15 - Sandbox-safe Go cache setup (recommended before running Go commands in restricted environments): 13 16 - `export GOCACHE=/tmp/go-build`
+3
README.md
··· 30 30 ``` 31 31 if you change input css or any *.html 32 32 33 + ## Cache Key Layout 34 + See [docs/cache-layout.md](docs/cache-layout.md) for the authoritative cache key/prefix layout and backend notes. 35 + 33 36 ## Frontend Approach 34 37 - Prefer server-rendered HTML and HTMX for interactive behavior. 35 38 - Avoid SPA-style architecture for routine page interactions.
+1 -1
cmd/careme/web.go
··· 45 45 46 46 userStorage := users.NewStorage(cache) 47 47 48 - generator, err := recipes.NewGenerator(cfg, cache) 48 + generator, err := recipes.NewGenerator(cfg, recipes.IO(cache)) 49 49 if err != nil { 50 50 return fmt.Errorf("failed to create recipe generator: %w", err) 51 51 }
+1 -1
cmd/careme/web_e2e_test.go
··· 163 163 cacheStore := cache.NewFileCache(cacheDir) 164 164 userStorage := users.NewStorage(cacheStore) 165 165 166 - generator, err := recipes.NewGenerator(cfg, cacheStore) 166 + generator, err := recipes.NewGenerator(cfg, recipes.IO(cacheStore)) 167 167 if err != nil { 168 168 t.Fatalf("failed to create generator: %v", err) 169 169 }
+1 -1
cmd/ingredients/main.go
··· 30 30 log.Fatalf("failed to load configuration: %s", err) 31 31 } 32 32 33 - generator, err := recipes.NewGenerator(cfg, cache) 33 + generator, err := recipes.NewGenerator(cfg, recipes.IO(cache)) 34 34 if err != nil { 35 35 log.Fatalf("failed to create recipe generator: %s", err) 36 36 }
+1 -1
cmd/producecheck/main.go
··· 48 48 log.Fatalf("failed to create cache: %v", err) 49 49 } 50 50 51 - generator, err := recipes.NewGenerator(cfg, cacheStore) 51 + generator, err := recipes.NewGenerator(cfg, recipes.IO(cacheStore)) 52 52 if err != nil { 53 53 log.Fatalf("failed to create recipe generator: %v", err) 54 54 }
+4 -13
docs/cache-layout.md
··· 6 6 7 7 The same cache keys are used in both backends. Keys with `/` become subdirectories (filesystem) or blob prefixes (Azure). 8 8 9 - ## Subdirectories / Prefixes 9 + ## Key Prefixes 10 10 11 11 | Prefix | Stored value | Written by | Read by | 12 12 | --- | --- | --- | --- | 13 13 | `shoppinglist/` | JSON `ai.ShoppingList` keyed by shopping hash | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`FromCache`) | 14 - | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`) | 14 + | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash (staples) or by wine style/date/location hash (wine candidate cache) | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | 15 15 | `params/` | JSON `generatorParams` keyed by shopping hash | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 16 16 | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveRecipes`) | `internal/recipes/io.go` (`SingleFromCache`) | 17 + | `wine_recommendations/` | Plain text wine recommendation keyed by recipe hash | `internal/recipes/wine.go` (`SaveWine`) via `internal/recipes/server.go` (`handleWine`) | `internal/recipes/wine.go` (`WineFromCache`) via `internal/recipes/server.go` (`handleWine`) | 17 18 | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) | 18 19 | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | 19 20 | `recipe_feedback/` | JSON `RecipeFeedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 20 21 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 21 22 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 22 23 23 - ## Legacy Root Keys 24 - 25 - Legacy root keys may still exist from older deployments and are treated as migration sources: 26 - 27 - - `<legacy_seeded_shopping_hash>`: legacy shopping list payload 28 - - `<legacy_seeded_location_hash>`: legacy ingredient payload 29 - - `<legacy_seeded_shopping_hash>.params`: legacy params payload 30 - 31 24 ## Notes 32 25 33 26 - Cache backend selection is in `internal/cache/azure.go` (`MakeCache`). 34 27 - Local cache path is `cache/` when filesystem backend is used. 35 28 - Blob names in Azure match the same key strings listed above. 36 - - Back compatibility: shopping list reads check `shoppinglist/<canonical_hash>` first, then legacy root `<legacy_seeded_shopping_hash>`. Ingredient reads check `ingredients/<canonical_hash>` first, then legacy root `<legacy_seeded_location_hash>`. Params reads check `params/<canonical_hash>` first, then legacy root `<legacy_seeded_shopping_hash>.params`. 37 - - Hash compatibility: canonical shopping and location hashes are now raw FNV64 URL-safe base64. Legacy seeded hashes (prefixed with `recipe`/`ingredients` in decoded bytes) are still supported for reads; `/recipes?h=...` redirects legacy shopping hashes to canonical hashes. 38 - - Migration utility: `cmd/moveshoppinglists` copies legacy root keys into prefixed canonical keys (`shoppinglist/`, `ingredients/`, `params/`) and transforms seeded legacy hashes to canonical hashes during copy. 29 + - Do not create nested keys under `recipe/<hash>` (for example `recipe/<hash>/wine`) because `FileCache` stores `recipe/<hash>` as a file path.
+1 -1
internal/mail/mail.go
··· 58 58 59 59 userStorage := users.NewStorage(cache) 60 60 61 - generator, err := recipes.NewGenerator(cfg, cache) 61 + generator, err := recipes.NewGenerator(cfg, recipes.IO(cache)) 62 62 if err != nil { 63 63 return nil, fmt.Errorf("failed to create recipe generator: %w", err) 64 64 }
+72 -63
internal/recipes/generator.go
··· 7 7 "careme/internal/kroger" 8 8 "careme/internal/locations" 9 9 "context" 10 + "encoding/base64" 10 11 "encoding/json" 11 12 "errors" 12 13 "fmt" 14 + "hash/fnv" 15 + "io" 13 16 "log/slog" 14 17 "net/http" 15 18 "slices" 16 19 "strconv" 17 20 "strings" 18 - "sync" 19 21 "time" 20 22 21 23 "github.com/samber/lo" ··· 27 29 Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) 28 30 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 29 31 Ready(ctx context.Context) error 32 + } 33 + 34 + type ingredientio interface { 35 + SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error 36 + IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) 30 37 } 31 38 32 39 type Generator struct { 33 40 config *config.Config 34 41 aiClient aiClient 35 42 krogerClient kroger.ClientWithResponsesInterface // probably need only subset 36 - cache cache.Cache 43 + io ingredientio 37 44 } 38 45 39 - func NewGenerator(cfg *config.Config, cache cache.Cache) (generator, error) { 46 + func NewGenerator(cfg *config.Config, io ingredientio) (generator, error) { 40 47 if cfg.Mocks.Enable { 41 48 return mock{}, nil 42 49 } ··· 46 53 return nil, err 47 54 } 48 55 return &Generator{ 49 - cache: cache, // should this also pull from config? 56 + io: io, 50 57 config: cfg, 51 58 aiClient: ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), 52 59 krogerClient: client, 53 60 }, nil 54 61 } 55 62 56 - func (g *Generator) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe) (string, error) { 57 - styles := make([]string, 0, len(recipe.WineStyles)+1) 63 + func (g *Generator) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (string, error) { 64 + var styles []string 58 65 for _, style := range recipe.WineStyles { 59 66 style = strings.TrimSpace(style) 60 - if style != "" { 67 + if style != "" { //would this ever happen? 61 68 styles = append(styles, style) 62 69 } 63 70 } 64 71 if len(styles) == 0 { 65 - return "", fmt.Errorf("no wine styles available for recipe %q", recipe.Title) 72 + return "No wine Styles for recipe", nil 66 73 } 67 - 68 - wines := []kroger.Ingredient{} 69 - var wg sync.WaitGroup 70 - var lock sync.Mutex 71 - var firstErr error 72 - 73 - wg.Add(len(styles)) 74 - for _, style := range styles { 75 - go func(style string) { 76 - defer wg.Done() 74 + dateStr := date.Format("2006-01-02") 75 + wines, err := asParallel(styles, func(style string) ([]kroger.Ingredient, error) { 76 + cacheKey := wineIngredientsCacheKey(style, location, date) 77 + winesOfStyle, err := g.io.IngredientsFromCache(ctx, cacheKey) 78 + if err == nil { 79 + slog.InfoContext(ctx, "Serving cached wines for style", "style", style, "location", location, "date", dateStr, "count", len(winesOfStyle)) 80 + return winesOfStyle, nil 81 + } 82 + if !errors.Is(err, cache.ErrNotFound) { 83 + slog.ErrorContext(ctx, "Failed to read cached wines for style", "style", style, "location", location, "date", dateStr, "error", err) 84 + } 77 85 78 - slog.InfoContext(ctx, "Picking wine for style", "style", style) 79 - // need to cache this. 80 - winesOfStyle, err := g.GetIngredients(ctx, location, Filter(style, []string{"*"}, false), 0) 86 + slog.InfoContext(ctx, "Picking wine for style", "style", style) 87 + winesOfStyle, err = g.GetIngredients(ctx, location, Filter(style, []string{"*"}, false), 0) 88 + if err != nil { 89 + slog.ErrorContext(ctx, "Failed to get ingredients for wine style", "style", style, "error", err) 90 + return nil, fmt.Errorf("failed to get ingredients for style %q: %w", style, err) 91 + } 81 92 82 - lock.Lock() 83 - defer lock.Unlock() 84 - if err != nil { 85 - slog.ErrorContext(ctx, "Failed to get ingredients for wine style", "style", style, "error", err) 86 - if firstErr == nil { 87 - firstErr = err 88 - } 89 - return 90 - } 91 - wines = append(wines, winesOfStyle...) 92 - }(style) 93 - } 94 - wg.Wait() 95 - if firstErr != nil { 96 - return "", firstErr 93 + if err := g.io.SaveIngredients(ctx, cacheKey, winesOfStyle); err != nil { 94 + slog.ErrorContext(ctx, "Failed to cache wines for style", "style", style, "location", location, "date", dateStr, "error", err) 95 + } 96 + return winesOfStyle, nil 97 + }) 98 + if err != nil { 99 + return "", err 97 100 } 98 101 99 102 if len(wines) == 0 { 100 - return "no wines found ", nil 103 + return "no wines of those styles found", nil 101 104 } 102 - wines = lo.UniqBy(wines, func(i kroger.Ingredient) string { return strings.ToLower(toStr(i.Description)) }) 105 + wines = uniqueByDescription(wines) 103 106 104 107 var sb strings.Builder 105 - sb.WriteString(fmt.Sprintf("Pick a wine that would go well with %q. Here are %d wines in TSV format.\n", recipe.Title, len(wines))) 106 - err := kroger.ToTSV(wines, &sb) 108 + _ = lo.Must(fmt.Fprintf(&sb, "Pick a wine that would go well with %q. Here are %d wines in TSV format.\n", recipe.Title, len(wines))) 109 + err = kroger.ToTSV(wines, &sb) 107 110 if err != nil { 108 111 slog.ErrorContext(ctx, "Failed to convert wines to TSV", "error", err) 109 112 return "", err ··· 180 183 func (g *Generator) GetStaples(ctx context.Context, p *generatorParams) ([]kroger.Ingredient, error) { 181 184 lochash := p.LocationHash() 182 185 var ingredients []kroger.Ingredient 183 - rio := IO(g.cache) 184 186 185 - if cachedIngredients, err := rio.IngredientsFromCache(ctx, lochash); err == nil { 187 + if cachedIngredients, err := g.io.IngredientsFromCache(ctx, lochash); err == nil { 186 188 slog.Info("serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(cachedIngredients)) 187 189 return cachedIngredients, nil 188 190 } else if !errors.Is(err, cache.ErrNotFound) { 189 191 slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 190 192 } 191 193 192 - var wg sync.WaitGroup 193 - var lock sync.Mutex 194 - wg.Add(len(p.Staples)) 195 - for _, category := range p.Staples { 196 - go func(category filter) { 197 - defer wg.Done() 198 - cingredients, err := g.GetIngredients(ctx, p.Location.ID, category, 0) 199 - if err != nil { 200 - slog.ErrorContext(ctx, "failed to get ingredients", "category", category.Term, "location", p.Location.ID, "error", err) 201 - return 202 - } 203 - lock.Lock() 204 - defer lock.Unlock() 205 - ingredients = append(ingredients, cingredients...) 206 - ingredients = lo.UniqBy(ingredients, func(i kroger.Ingredient) string { return toStr(i.Description) }) 207 - slog.InfoContext(ctx, "Found ingredients for category", "count", len(cingredients), "category", category.Term, "location", p.Location.ID, "runningtotal", len(ingredients)) 208 - }(category) 194 + ingredients, err := asParallel(p.Staples, func(category filter) ([]kroger.Ingredient, error) { 195 + cingredients, err := g.GetIngredients(ctx, p.Location.ID, category, 0) 196 + if err != nil { 197 + slog.ErrorContext(ctx, "failed to get ingredients", "category", category.Term, "location", p.Location.ID, "error", err) 198 + return nil, err 199 + } 200 + slog.InfoContext(ctx, "Found ingredients for category", "count", len(cingredients), "category", category.Term, "location", p.Location.ID, "runningtotal", len(ingredients)) 201 + return cingredients, nil 202 + }) 203 + if err != nil { 204 + return nil, fmt.Errorf("failed to get ingredients for staples: %w", err) 209 205 } 210 - 211 - wg.Wait() 212 - 206 + ingredients = uniqueByDescription(ingredients) 213 207 mutable.Shuffle(ingredients) 214 208 215 - if err := rio.SaveIngredients(ctx, p.LocationHash(), ingredients); err != nil { 209 + if err := g.io.SaveIngredients(ctx, p.LocationHash(), ingredients); err != nil { 216 210 slog.ErrorContext(ctx, "failed to cache ingredients", "location", p.String(), "error", err) 217 211 return nil, err 218 212 } 219 213 return ingredients, nil 214 + } 215 + 216 + func uniqueByDescription(ingredients []kroger.Ingredient) []kroger.Ingredient { 217 + return lo.UniqBy(ingredients, func(i kroger.Ingredient) string { 218 + return toStr(i.Description) 219 + }) 220 220 } 221 221 222 222 // move to krogrer client as everyone will be differnt here? ··· 328 328 } 329 329 return *s 330 330 } 331 + 332 + func wineIngredientsCacheKey(style, location string, date time.Time) string { 333 + normalizedStyle := strings.ToLower(strings.TrimSpace(style)) 334 + fnv := fnv.New64a() 335 + lo.Must(io.WriteString(fnv, location)) 336 + lo.Must(io.WriteString(fnv, date.Format("2006-01-02"))) 337 + lo.Must(io.WriteString(fnv, normalizedStyle)) 338 + return "wines/" + base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 339 + }
+92
internal/recipes/generator_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/ai" 5 + "careme/internal/cache" 6 + "careme/internal/kroger" 7 + "careme/internal/locations" 8 + "context" 9 + "strings" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + type panicKrogerClient struct { 15 + kroger.ClientWithResponsesInterface 16 + } 17 + 18 + func (panicKrogerClient) ProductSearchWithResponse(ctx context.Context, params *kroger.ProductSearchParams, reqEditors ...kroger.RequestEditorFn) (*kroger.ProductSearchResponse, error) { 19 + panic("unexpected call to ProductSearchWithResponse") 20 + } 21 + 22 + type captureWineQuestionAIClient struct { 23 + question string 24 + answer string 25 + } 26 + 27 + func (c *captureWineQuestionAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 28 + panic("unexpected call to GenerateRecipes") 29 + } 30 + 31 + func (c *captureWineQuestionAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 32 + panic("unexpected call to Regenerate") 33 + } 34 + 35 + func (c *captureWineQuestionAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 36 + c.question = question 37 + return c.answer, nil 38 + } 39 + 40 + func (c *captureWineQuestionAIClient) Ready(ctx context.Context) error { 41 + return nil 42 + } 43 + 44 + func TestWineIngredientsCacheKey_UsesStyleDateAndLocation(t *testing.T) { 45 + got := wineIngredientsCacheKey(" Pinot Noir ", "70500874", time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC)) 46 + want := "wines/0XY3COdxwHk" 47 + if got != want { 48 + t.Fatalf("unexpected cache key: got %q want %q", got, want) 49 + } 50 + } 51 + 52 + func TestPickAWine_UsesCachedIngredientsForStyleDateAndLocation(t *testing.T) { 53 + const ( 54 + location = "70500874" 55 + conversation = "conv-1" 56 + style = "Pinot Noir" 57 + ) 58 + cacheDate := time.Date(2026, 2, 1, 8, 0, 0, 0, time.UTC) 59 + 60 + cacheStore := cache.NewFileCache(t.TempDir()) 61 + rio := IO(cacheStore) 62 + cached := []kroger.Ingredient{ 63 + { 64 + Description: loPtr("Cached Pinot Noir"), 65 + Size: loPtr("750mL"), 66 + }, 67 + } 68 + if err := rio.SaveIngredients(t.Context(), wineIngredientsCacheKey(style, location, cacheDate), cached); err != nil { 69 + t.Fatalf("failed to seed wine ingredients cache: %v", err) 70 + } 71 + 72 + aiStub := &captureWineQuestionAIClient{answer: "Great with your dish."} 73 + g := &Generator{ 74 + io: IO(cacheStore), 75 + aiClient: aiStub, 76 + krogerClient: panicKrogerClient{}, 77 + } 78 + 79 + got, err := g.PickAWine(t.Context(), conversation, location, ai.Recipe{ 80 + Title: "Roast Chicken", 81 + WineStyles: []string{style}, 82 + }, cacheDate) 83 + if err != nil { 84 + t.Fatalf("PickAWine returned error: %v", err) 85 + } 86 + if got != aiStub.answer { 87 + t.Fatalf("unexpected answer: got %q want %q", got, aiStub.answer) 88 + } 89 + if !strings.Contains(aiStub.question, "Cached Pinot Noir") { 90 + t.Fatalf("expected cached wine to appear in question payload, got: %s", aiStub.question) 91 + } 92 + }
+27
internal/recipes/io_test.go
··· 149 149 } 150 150 } 151 151 152 + func TestSaveWine_UsesNonConflictingPrefixWhenRecipeKeyAlreadyExists(t *testing.T) { 153 + tmpDir := t.TempDir() 154 + cacheStore := cache.NewFileCache(tmpDir) 155 + rio := IO(cacheStore) 156 + 157 + hash := "recipe-hash" 158 + if err := cacheStore.Put(t.Context(), recipeCachePrefix+hash, `{"title":"Roast Chicken"}`, cache.Unconditional()); err != nil { 159 + t.Fatalf("failed to seed recipe entry: %v", err) 160 + } 161 + 162 + if err := rio.SaveWine(t.Context(), hash, "Try a tempranillo."); err != nil { 163 + t.Fatalf("SaveWine failed: %v", err) 164 + } 165 + 166 + if _, err := os.Stat(filepath.Join(tmpDir, wineRecommendationsCachePrefix, hash)); err != nil { 167 + t.Fatalf("expected wine recommendation at prefixed key: %v", err) 168 + } 169 + 170 + got, err := rio.WineFromCache(t.Context(), hash) 171 + if err != nil { 172 + t.Fatalf("WineFromCache failed: %v", err) 173 + } 174 + if got != "Try a tempranillo." { 175 + t.Fatalf("unexpected cached wine recommendation: got %q", got) 176 + } 177 + } 178 + 152 179 func loPtr(v string) *string { 153 180 return &v 154 181 }
+2 -1
internal/recipes/mock.go
··· 397 397 return fmt.Sprintf("Mock answer: %s", question), nil 398 398 } 399 399 400 - func (m mock) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe) (string, error) { 400 + func (m mock) PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (string, error) { 401 401 _ = ctx 402 402 _ = conversationID 403 + _ = date 403 404 return fmt.Sprintf("Mock wine pick for %s: try a medium-bodied red.", recipe.Title), nil 404 405 }
+36
internal/recipes/parallel.go
··· 1 + package recipes 2 + 3 + import ( 4 + "errors" 5 + 6 + lop "github.com/samber/lo/parallel" 7 + ) 8 + 9 + // we need to make a bunch of calls and merge results but not lose track of errors. 10 + func asParallel[T any, T2 any](items []T, fn func(T) ([]T2, error)) ([]T2, error) { 11 + if len(items) == 0 { 12 + return []T2{}, nil 13 + } 14 + 15 + type result struct { 16 + values []T2 17 + err error 18 + } 19 + 20 + mapped := lop.Map(items, func(item T, _ int) result { 21 + values, err := fn(item) 22 + return result{values: values, err: err} 23 + }) 24 + 25 + merged := make([]T2, 0) 26 + errs := make([]error, 0) 27 + for _, r := range mapped { 28 + if r.err != nil { 29 + errs = append(errs, r.err) 30 + continue 31 + } 32 + merged = append(merged, r.values...) 33 + } 34 + 35 + return merged, errors.Join(errs...) 36 + }
+51
internal/recipes/parallel_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "errors" 5 + "slices" 6 + "testing" 7 + ) 8 + 9 + func TestAsParallel_MergesResultsAndErrors(t *testing.T) { 10 + errOne := errors.New("err one") 11 + errTwo := errors.New("err two") 12 + 13 + got, err := asParallel([]int{1, 2, 3, 4}, func(i int) ([]string, error) { 14 + switch i { 15 + case 1: 16 + return []string{"a", "b"}, nil 17 + case 2: 18 + return []string{"c"}, errOne 19 + case 3: 20 + return nil, errTwo 21 + case 4: 22 + return []string{"d"}, nil 23 + default: 24 + return nil, nil 25 + } 26 + }) 27 + 28 + slices.Sort(got) 29 + want := []string{"a", "b", "d"} 30 + if !slices.Equal(got, want) { 31 + t.Fatalf("unexpected merged results: got=%v want=%v", got, want) 32 + } 33 + if !errors.Is(err, errOne) { 34 + t.Fatalf("expected merged error to include errOne, got: %v", err) 35 + } 36 + if !errors.Is(err, errTwo) { 37 + t.Fatalf("expected merged error to include errTwo, got: %v", err) 38 + } 39 + } 40 + 41 + func TestAsParallel_EmptyInput(t *testing.T) { 42 + got, err := asParallel([]string{}, func(s string) ([]int, error) { 43 + return []int{1}, nil 44 + }) 45 + if err != nil { 46 + t.Fatalf("expected nil error for empty input, got: %v", err) 47 + } 48 + if len(got) != 0 { 49 + t.Fatalf("expected empty result for empty input, got: %v", got) 50 + } 51 + }
+11 -2
internal/recipes/server.go
··· 36 36 type generator interface { 37 37 GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) 38 38 AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 39 - PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe) (string, error) 39 + PickAWine(ctx context.Context, conversationID string, location string, recipe ai.Recipe, date time.Time) (string, error) 40 40 Ready(ctx context.Context) error 41 41 } 42 42 ··· 230 230 http.Error(w, "missing recipe hash", http.StatusBadRequest) 231 231 return 232 232 } 233 + if recommendation, err := s.WineFromCache(ctx, hash); err == nil { 234 + FormatRecipeWineHTML(hash, recommendation, w) 235 + return 236 + } else if !errors.Is(err, cache.ErrNotFound) { 237 + slog.ErrorContext(ctx, "failed to load cached wine recommendation", "hash", hash, "error", err) 238 + } 233 239 234 240 recipe, err := s.SingleFromCache(ctx, hash) 235 241 if err != nil { ··· 257 263 258 264 ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second) 259 265 defer cancel() 260 - recommendation, err := s.generator.PickAWine(ctx, conversationID, p.Location.ID, *recipe) 266 + recommendation, err := s.generator.PickAWine(ctx, conversationID, p.Location.ID, *recipe, p.Date) 261 267 if err != nil { 262 268 slog.ErrorContext(ctx, "failed to pick wine", "hash", hash, "conversation_id", conversationID, "error", err) 263 269 http.Error(w, "failed to pick wine", http.StatusInternalServerError) 264 270 return 271 + } 272 + if err := s.SaveWine(ctx, hash, recommendation); err != nil { 273 + slog.ErrorContext(ctx, "failed to save wine recommendation", "hash", hash, "error", err) 265 274 } 266 275 267 276 FormatRecipeWineHTML(hash, recommendation, w)
+89 -1
internal/recipes/server_test.go
··· 291 291 lastWinePick struct { 292 292 conversationID string 293 293 recipeTitle string 294 + date time.Time 294 295 } 296 + wineRecommendation string 297 + winePickCalls int 298 + panicOnWine bool 295 299 } 296 300 297 301 func (c *captureQuestionGenerator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { ··· 303 307 return "Try chicken thighs at the same cook time.", nil 304 308 } 305 309 306 - func (c *captureQuestionGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe) (string, error) { 310 + func (c *captureQuestionGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (string, error) { 311 + if c.panicOnWine { 312 + panic("unexpected call to PickAWine") 313 + } 314 + c.winePickCalls++ 307 315 c.lastWinePick.conversationID = conversationID 308 316 c.lastWinePick.recipeTitle = recipe.Title 317 + c.lastWinePick.date = date 318 + if c.wineRecommendation != "" { 319 + return c.wineRecommendation, nil 320 + } 309 321 return "Try a chilled sauvignon blanc.", nil 310 322 } 311 323 ··· 481 493 } 482 494 if got, want := g.lastWinePick.recipeTitle, "Roast Chicken"; got != want { 483 495 t.Fatalf("expected recipe title %q, got %q", want, got) 496 + } 497 + if got, want := g.lastWinePick.date.Format("2006-01-02"), p.Date.Format("2006-01-02"); got != want { 498 + t.Fatalf("expected wine date %q, got %q", want, got) 499 + } 500 + if got, want := g.winePickCalls, 1; got != want { 501 + t.Fatalf("expected PickAWine call count %d, got %d", want, got) 502 + } 503 + } 504 + 505 + func TestHandleWine_UsesCachedWineRecommendation(t *testing.T) { 506 + cacheStore := cache.NewInMemoryCache() 507 + g1 := &captureQuestionGenerator{wineRecommendation: "Try a crisp riesling."} 508 + s1 := &server{ 509 + recipeio: recipeio{Cache: cacheStore}, 510 + storage: users.NewStorage(cacheStore), 511 + clerk: auth.DefaultMock(), 512 + generator: g1, 513 + } 514 + 515 + p := DefaultParams(&locations.Location{ID: "loc-wine", Name: "Wine Test Store"}, time.Now()) 516 + p.ConversationID = "conv-wine" 517 + originHash := p.Hash() 518 + if err := s1.SaveParams(t.Context(), p); err != nil { 519 + t.Fatalf("failed to save params: %v", err) 520 + } 521 + recipe := ai.Recipe{ 522 + OriginHash: originHash, 523 + Title: "Roast Chicken", 524 + Description: "Crisp skin and herbs.", 525 + Ingredients: []ai.Ingredient{{Name: "chicken", Quantity: "1", Price: "$12"}}, 526 + Instructions: []string{"Roast until done."}, 527 + WineStyles: []string{"pinot noir"}, 528 + } 529 + recipeHash := recipe.ComputeHash() 530 + if err := s1.SaveRecipes(t.Context(), []ai.Recipe{recipe}, originHash); err != nil { 531 + t.Fatalf("failed to save recipe: %v", err) 532 + } 533 + 534 + req1 := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/wine", nil) 535 + req1.Header.Set("HX-Request", "true") 536 + req1.SetPathValue("hash", recipeHash) 537 + rr1 := httptest.NewRecorder() 538 + s1.handleWine(rr1, req1) 539 + 540 + if rr1.Code != http.StatusOK { 541 + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, rr1.Code, rr1.Body.String()) 542 + } 543 + if !strings.Contains(rr1.Body.String(), "Try a crisp riesling.") { 544 + t.Fatalf("expected initial recommendation in response, got body: %s", rr1.Body.String()) 545 + } 546 + if got, want := g1.winePickCalls, 1; got != want { 547 + t.Fatalf("expected PickAWine call count %d, got %d", want, got) 548 + } 549 + 550 + g2 := &captureQuestionGenerator{panicOnWine: true} 551 + s2 := &server{ 552 + recipeio: recipeio{Cache: cacheStore}, 553 + storage: users.NewStorage(cacheStore), 554 + clerk: auth.DefaultMock(), 555 + generator: g2, 556 + } 557 + 558 + req2 := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/wine", nil) 559 + req2.Header.Set("HX-Request", "true") 560 + req2.SetPathValue("hash", recipeHash) 561 + rr2 := httptest.NewRecorder() 562 + s2.handleWine(rr2, req2) 563 + 564 + if rr2.Code != http.StatusOK { 565 + t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, rr2.Code, rr2.Body.String()) 566 + } 567 + if !strings.Contains(rr2.Body.String(), "Try a crisp riesling.") { 568 + t.Fatalf("expected cached recommendation in response, got body: %s", rr2.Body.String()) 569 + } 570 + if got, want := g2.winePickCalls, 0; got != want { 571 + t.Fatalf("expected PickAWine call count %d, got %d", want, got) 484 572 } 485 573 } 486 574
+40
internal/recipes/wine.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "context" 6 + "io" 7 + "log/slog" 8 + ) 9 + 10 + const wineRecommendationsCachePrefix = "wine_recommendations/" 11 + 12 + func recipeWineCacheKey(hash string) string { 13 + return wineRecommendationsCachePrefix + hash 14 + } 15 + 16 + func (rio recipeio) WineFromCache(ctx context.Context, hash string) (string, error) { 17 + return rio.readStringFromCache(ctx, recipeWineCacheKey(hash)) 18 + } 19 + 20 + func (rio recipeio) SaveWine(ctx context.Context, hash string, recommendation string) error { 21 + return rio.Cache.Put(ctx, recipeWineCacheKey(hash), recommendation, cache.Unconditional()) 22 + } 23 + 24 + func (rio recipeio) readStringFromCache(ctx context.Context, key string) (string, error) { 25 + reader, err := rio.Cache.Get(ctx, key) 26 + if err != nil { 27 + return "", err 28 + } 29 + defer func() { 30 + if err := reader.Close(); err != nil { 31 + slog.ErrorContext(ctx, "failed to close cached string reader", "key", key, "error", err) 32 + } 33 + }() 34 + 35 + body, err := io.ReadAll(reader) 36 + if err != nil { 37 + return "", err 38 + } 39 + return string(body), nil 40 + }