ai cooking
0
fork

Configure Feed

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

Merge branch 'master' into gpt55switch

authored by

Paul Miller and committed by
GitHub
92014536 c7e31282

+364 -325
+17 -36
internal/actowiz/staples.go
··· 10 10 "strconv" 11 11 "strings" 12 12 13 - "careme/internal/kroger" 13 + "careme/internal/ai" 14 14 ) 15 15 16 16 const LocationIDPrefix = "safeway_" ··· 47 47 return storeID != "" && storeID != locationID 48 48 } 49 49 50 - func all() []kroger.Ingredient { 50 + func all() []ai.InputIngredient { 51 51 // do this once instead of every time? 52 - ingredients := make([]kroger.Ingredient, 0, len(embeddedSafewayProducts)) 52 + ingredients := make([]ai.InputIngredient, 0, len(embeddedSafewayProducts)) 53 53 for _, product := range embeddedSafewayProducts { 54 54 if !product.Availability { 55 55 continue ··· 68 68 return ingredients 69 69 } 70 70 71 - func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]kroger.Ingredient, error) { 71 + func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]ai.InputIngredient, error) { 72 72 if !p.IsID(locationID) { 73 73 return nil, fmt.Errorf("invalid safeway location id %q", locationID) 74 74 } 75 75 return all(), nil 76 76 } 77 77 78 - func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, _ int) ([]kroger.Ingredient, error) { 78 + func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, _ int) ([]ai.InputIngredient, error) { 79 79 if !p.IsID(locationID) { 80 80 return nil, fmt.Errorf("invalid safeway location id %q", locationID) 81 81 } ··· 83 83 return filterIngredients(all(), searchTerm), nil 84 84 } 85 85 86 - func filterIngredients(ingredients []kroger.Ingredient, searchTerm string) []kroger.Ingredient { 86 + func filterIngredients(ingredients []ai.InputIngredient, searchTerm string) []ai.InputIngredient { 87 87 searchTerm = strings.TrimSpace(strings.ToLower(searchTerm)) 88 88 if searchTerm == "" { 89 89 return slices.Clone(ingredients) 90 90 } 91 91 92 - filtered := make([]kroger.Ingredient, 0, len(ingredients)) 92 + filtered := make([]ai.InputIngredient, 0, len(ingredients)) 93 93 for _, ingredient := range ingredients { 94 94 if ingredientMatches(ingredient, searchTerm) { 95 95 filtered = append(filtered, ingredient) ··· 98 98 return filtered 99 99 } 100 100 101 - func ingredientMatches(ingredient kroger.Ingredient, searchTerm string) bool { 101 + func ingredientMatches(ingredient ai.InputIngredient, searchTerm string) bool { 102 102 for _, value := range []string{ 103 - stringValue(ingredient.Description), 104 - stringValue(ingredient.Brand), 103 + ingredient.Description, 104 + ingredient.Brand, 105 105 } { 106 106 if strings.Contains(strings.ToLower(value), searchTerm) { 107 107 return true ··· 111 111 return false 112 112 } 113 113 114 - func productToIngredient(product SafewayProduct) kroger.Ingredient { 114 + func productToIngredient(product SafewayProduct) ai.InputIngredient { 115 115 description, size := splitProductName(product.ProductName) // dubious size is really always 116 116 regularPrice := float32Ptr(product.MRP) 117 117 salePrice := float32Ptr(product.DiscountedPrice) ··· 121 121 122 122 productID := strconv.FormatInt(product.ID, 10) 123 123 categories := compactStrings([]string{product.Category, product.SubCategory}) 124 - var categoryPtr *[]string 125 - if len(categories) > 0 { 126 - categoryPtr = &categories 127 - } 128 124 129 - return kroger.Ingredient{ 130 - ProductId: stringPtr(productID), 131 - Description: stringPtr(description), 132 - Size: stringPtr(size), 125 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 126 + ProductID: productID, 127 + Description: description, 128 + Size: size, 133 129 PriceRegular: regularPrice, 134 130 PriceSale: salePrice, 135 - Categories: categoryPtr, 136 - } 131 + Categories: categories, 132 + }) 137 133 } 138 134 139 135 func splitProductName(name string) (string, string) { ··· 163 159 panic(fmt.Errorf("decode safeway products: %w", err)) 164 160 } 165 161 return products 166 - } 167 - 168 - func stringValue(value *string) string { 169 - if value == nil { 170 - return "" 171 - } 172 - return *value 173 - } 174 - 175 - func stringPtr(value string) *string { 176 - value = strings.TrimSpace(value) 177 - if value == "" { 178 - return nil 179 - } 180 - return &value 181 162 } 182 163 183 164 func float32Ptr(value *float64) *float32 {
+5 -5
internal/actowiz/staples_test.go
··· 25 25 } 26 26 27 27 first := got[0] 28 - if first.ProductId == nil || *first.ProductId == "" { 29 - t.Fatalf("unexpected product id: %+v", first.ProductId) 28 + if first.ProductID == "" { 29 + t.Fatalf("unexpected product id: %+v", first.ProductID) 30 30 } 31 - if first.Description == nil || *first.Description == "" { 31 + if first.Description == "" { 32 32 t.Fatalf("unexpected description: %+v", first.Description) 33 33 } 34 - if first.Categories == nil || len(*first.Categories) == 0 { 34 + if len(first.Categories) == 0 { 35 35 t.Fatalf("unexpected categories: %+v", first.Categories) 36 36 } 37 37 } ··· 58 58 if len(got) == 0 { 59 59 t.Fatal("expected filtered ingredients after skip") 60 60 } 61 - if got[0].Description == nil { 61 + if got[0].Description == "" { 62 62 t.Fatalf("missing description: %+v", got[0]) 63 63 } 64 64 }
+3 -3
internal/ai/client.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - "careme/internal/locations" 14 + locationtypes "careme/internal/locations/types" 15 15 16 16 openai "github.com/openai/openai-go/v3" 17 17 "github.com/openai/openai-go/v3/option" ··· 373 373 return &selection, nil 374 374 } 375 375 376 - func (c *client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 376 + func (c *client) GenerateRecipes(ctx context.Context, location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 377 377 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes) 378 378 if err != nil { 379 379 return nil, fmt.Errorf("failed to build recipe messages: %w", err) ··· 439 439 } 440 440 441 441 // buildRecipeMessages creates separate messages for the LLM to process more efficiently 442 - func (c *client) buildRecipeMessages(location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 442 + func (c *client) buildRecipeMessages(location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 443 443 var messages []responses.ResponseInputItemUnionParam 444 444 // constants we might make variable later 445 445 messages = append(messages, user("Prioritize ingredients that are in season for the current date and user's state location "+date.Format("January 2nd")+" in "+location.State+"."))
+23 -1
internal/ai/ingredient_grade.go
··· 21 21 defaultIngredientGradeModel = openai.ChatModelGPT5Mini 22 22 ) 23 23 24 + // should we have category spefic grading prompts? 24 25 const ingredientGradeSystemInstruction = ` 25 26 You review grocery catalog items before they are shown to a home recipe generator. 26 27 ··· 37 38 - formats intended mainly for snacking or immediate eating rather than cooking 38 39 - pre-cut fruit unless it is still broadly useful for cooking or baking 39 40 41 + Additional rules for pasta, grains, rice, legumes, and noodles: 42 + 43 + - Prefer flexible base carbohydrates: 44 + rice, dry pasta, oats, quinoa, farro, freekah 45 + 46 + - Use simple score anchors: 47 + standard dry pasta → 6–7 48 + premium dry pasta → 8–9 49 + alternative pasta (chickpea, lentil, gluten-free) → 5–6 50 + bread → 5–6 51 + prepared sauces → max 6 52 + instant or flavored mixes → 3–5 53 + 54 + - Reward real cooking-performance signals: 55 + bronze-cut, slow-dried, high-protein durum, whole grain, hulled, pearled 56 + 57 + - Reward known higher-quality brands (e.g., Felicetti, De Cecco, Rummo, Rustichella) 58 + 59 + - Do not infer quality from generic terms: 60 + "quality", "non-GMO", "organic", "traditional" 61 + 62 + - Penalize items that are less flexible or more processed 40 63 41 64 Scoring anchors: 42 65 - 9-10: excellent raw/fresh flexible cooking ingredient, e.g. whole vegetables, greens, roots, raw meats, fresh fruit useful in baking/cooking ··· 50 73 51 74 Return JSON only. Preserve each input id/index exactly. Be concise.` 52 75 53 - // this is wire compatible with kroger.Ingredient eventually it should replace it in what staples returns 54 76 type InputIngredient struct { 55 77 ProductID string `json:"id,omitempty"` 56 78 AisleNumber string `json:"number,omitempty"` // this is a dumb json name fix it later
+10 -5
internal/albertsons/query/client.go
··· 12 12 "time" 13 13 ) 14 14 15 + // this is a strange set. Actual sub categories don't work but thes aisle-vs ones do. 16 + // for en example broke sub category here is beef https://www.safeway.com/shop/aisles/meat-seafood/beef.html?sort=&page=1&loc=1142 15 17 const ( 16 - Category_Vegatables = "GR-C-categ-8c62c848" 17 - Category_Fruit = "GR-C-categ-a8eea474" 18 - Category_Seafood = "GR-C-Categ-6090cd27" 19 - Category_Meat = "GR-MeatF-fffc8662" 20 - Category_Wine = "GR-S-Searc-db592d50" 18 + Category_Vegatables = "GR-C-categ-8c62c848" 19 + Category_Fruit = "GR-C-categ-a8eea474" 20 + Category_Seafood = "GR-C-Categ-6090cd27" // https://www.safeway.com/aisle-vs/meat-seafood/seafood-favorites.html 21 + Category_Meat = "GR-MeatF-fffc8662" // https://www.safeway.com/aisle-vs/meat-seafood/meat-favorites.html 22 + Category_Wine = "GR-S-Searc-db592d50" 23 + Category_Pasta_Grains = "GR-C-Categ-77b9d5dd" // https://www.safeway.com/aisle-vs/grains-pasta-sides/best-sellers.html 24 + Category_Dairy = "GR-C-Categ-f210e5cd" // new and trending seems dubious https://www.safeway.com/aisle-vs/dairy-eggs-cheese/new-trending.html 21 25 ) 22 26 23 27 func StapleCategories() []string { ··· 26 30 Category_Fruit, 27 31 Category_Seafood, 28 32 Category_Meat, 33 + Category_Pasta_Grains, 29 34 } 30 35 } 31 36
+25 -45
internal/albertsons/staples.go
··· 8 8 "net/http" 9 9 "strings" 10 10 11 + "careme/internal/ai" 11 12 "careme/internal/albertsons/query" 12 13 "careme/internal/cache" 13 14 "careme/internal/config" 14 - "careme/internal/kroger" 15 15 "careme/internal/parallelism" 16 16 17 17 "github.com/samber/lo" ··· 73 73 } 74 74 75 75 var stapleRows = map[string]uint{ 76 - query.Category_Vegatables: 150, // do we need way more of this? 77 - query.Category_Fruit: 100, 78 - query.Category_Meat: 100, 79 - query.Category_Seafood: 60, 76 + query.Category_Vegatables: 150, // do we need way more of this? 77 + query.Category_Fruit: 100, 78 + query.Category_Meat: 100, 79 + query.Category_Seafood: 60, 80 + query.Category_Pasta_Grains: 100, //??? 80 81 } 81 82 82 - func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 83 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 83 84 client, storeID, err := p.clientForLocation(locationID) 84 85 if err != nil { 85 86 return nil, err 86 87 } 87 88 88 - return parallelism.Flatten(query.StapleCategories(), func(category string) ([]kroger.Ingredient, error) { 89 + return parallelism.Flatten(query.StapleCategories(), func(category string) ([]ai.InputIngredient, error) { 89 90 payload, err := client.Search(ctx, storeID, category, query.SearchOptions{ 90 91 // how many rows? different per category? Should we paginate 91 92 Rows: stapleRows[category], ··· 96 97 return nil, err 97 98 } 98 99 99 - ingredients := lo.Map(payload.Response.Docs, func(product query.PathwaySearchProduct, _ int) kroger.Ingredient { 100 - return productToIngredient(product) 101 - }) 100 + ingredients := lo.Map(payload.Response.Docs, productToIngredient) 102 101 slog.InfoContext(ctx, "found albertsons staples for category", "count", len(ingredients), "category", category, "location", locationID) 103 102 return ingredients, nil 104 103 }) 105 104 } 106 105 107 106 // since this is mostly used by wine it isn't actuallyt they helpful. 108 - func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 107 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 109 108 client, storeID, err := p.clientForLocation(locationID) 110 109 if err != nil { 111 110 return nil, err ··· 113 112 114 113 // should we just resturn all instead of search term? how many is this? 115 114 payload, err := client.Search(ctx, storeID, query.Category_Wine, query.SearchOptions{ 116 - Query: searchTerm, Rows: 100, 115 + Query: searchTerm, Rows: 100, Start: uint(skip), 117 116 }) 118 117 if err != nil { 119 118 return nil, err 120 119 } 121 - 122 - ingredients := lo.Map(payload.Response.Docs, func(product query.PathwaySearchProduct, _ int) kroger.Ingredient { 123 - return productToIngredient(product) 124 - }) 125 - if skip >= len(ingredients) { 126 - return []kroger.Ingredient{}, nil 127 - } 128 - return ingredients[skip:], nil 120 + return lo.Map(payload.Response.Docs, productToIngredient), nil 129 121 } 130 122 131 123 // clientForLocation takes a prefixed store id and looks up chaing base url and returnes unprefixed id. ··· 156 148 return "", "", false 157 149 } 158 150 159 - func productToIngredient(product query.PathwaySearchProduct) kroger.Ingredient { 160 - productID := stringPtr(strings.TrimSpace(product.ID)) 161 - description := stringPtr(strings.TrimSpace(product.Name)) 151 + func productToIngredient(product query.PathwaySearchProduct, _ int) ai.InputIngredient { 152 + productID := strings.TrimSpace(product.ID) 153 + description := strings.TrimSpace(product.Name) 162 154 size := sizeText(product) 163 155 regularPrice := float32Ptr(product.BasePrice) 164 156 salePrice := float32Ptr(product.Price) 157 + // how does shelf relate to aisle? 165 158 categories := lo.Compact([]string{product.DepartmentName, product.ShelfName}) 166 159 167 - var categoryPtr *[]string 168 - if len(categories) > 0 { 169 - categoryPtr = &categories 170 - } 171 - 172 - return kroger.Ingredient{ 173 - ProductId: productID, 160 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 161 + ProductID: productID, 174 162 Description: description, 175 - Size: size, 163 + Size: size, // will product id smash this as a dedupe? 176 164 PriceRegular: regularPrice, 177 165 PriceSale: salePrice, 178 - Categories: categoryPtr, 179 - } 166 + Categories: categories, 167 + AisleNumber: product.AisleID, // also an aisle name if thats better? 168 + }) 180 169 } 181 170 182 171 // this is a bit squirely shouldn't we take one ratehr than joiing both? 183 - func sizeText(product query.PathwaySearchProduct) *string { 172 + func sizeText(product query.PathwaySearchProduct) string { 184 173 sizeParts := lo.Compact([]string{product.ItemSizeQty, product.UnitOfMeasure}) 185 174 if len(sizeParts) == 0 { 186 - return nil 175 + return "" 187 176 } 188 - size := strings.Join(sizeParts, " ") 189 - return &size 190 - } 191 - 192 - func stringPtr(value string) *string { 193 - value = strings.TrimSpace(value) 194 - if value == "" { 195 - return nil 196 - } 197 - return &value 177 + return strings.Join(sizeParts, " ") 198 178 } 199 179 200 180 func float32Ptr(value float64) *float32 {
+34 -13
internal/albertsons/staples_test.go
··· 30 30 results map[string]query.PathwaySearchPayload 31 31 mu sync.Mutex 32 32 calls []string 33 + starts []uint 33 34 } 34 35 35 36 func (s *stubSearchClient) Search(_ context.Context, storeID, category string, opts query.SearchOptions) (*query.PathwaySearchPayload, error) { 36 37 s.mu.Lock() 37 38 s.calls = append(s.calls, storeID+":"+category+":"+opts.Query) 39 + s.starts = append(s.starts, opts.Start) 38 40 s.mu.Unlock() 39 41 40 42 payload := s.results[category] ··· 47 49 return slices.Contains(s.calls, want) 48 50 } 49 51 52 + func (s *stubSearchClient) startForCall(want string) (uint, bool) { 53 + s.mu.Lock() 54 + defer s.mu.Unlock() 55 + for i, call := range s.calls { 56 + if call == want { 57 + return s.starts[i], true 58 + } 59 + } 60 + return 0, false 61 + } 62 + 50 63 func (s *stubSearchClient) callCount() int { 51 64 s.mu.Lock() 52 65 defer s.mu.Unlock() ··· 95 108 } 96 109 97 110 first := got[0] 98 - if first.ProductId == nil || *first.ProductId != "veg-1" { 99 - t.Fatalf("unexpected product id: %+v", first.ProductId) 111 + if first.ProductID != "veg-1" { 112 + t.Fatalf("unexpected product id: %+v", first.ProductID) 100 113 } 101 - if first.Description == nil || *first.Description != "Broccoli Crown" { 114 + if first.Description != "Broccoli Crown" { 102 115 t.Fatalf("unexpected description: %+v", first.Description) 103 116 } 104 - if first.Size == nil || *first.Size != "1 EA" { 117 + if first.Size != "1 EA" { 105 118 t.Fatalf("unexpected size: %+v", first.Size) 106 119 } 107 120 if first.PriceRegular == nil || *first.PriceRegular != float32(3.49) { ··· 110 123 if first.PriceSale == nil || *first.PriceSale != float32(2.99) { 111 124 t.Fatalf("unexpected sale price: %+v", first.PriceSale) 112 125 } 113 - if first.Categories == nil || !slices.Equal(*first.Categories, []string{"Produce", "Vegetables"}) { 126 + if !slices.Equal(first.Categories, []string{"Produce", "Vegetables"}) { 114 127 t.Fatalf("unexpected categories: %+v", first.Categories) 115 128 } 116 129 } ··· 158 171 if err != nil { 159 172 t.Fatalf("GetIngredients returned error: %v", err) 160 173 } 161 - if !client.hasCall("806:" + query.Category_Wine + ":pinot") { 174 + searchCall := "806:" + query.Category_Wine + ":pinot" 175 + if !client.hasCall(searchCall) { 162 176 t.Fatalf("missing expected search call") 163 177 } 164 - if len(got) != 1 { 165 - t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 178 + if got, ok := client.startForCall(searchCall); !ok || got != 1 { 179 + t.Fatalf("unexpected search start: got %d found %t", got, ok) 166 180 } 167 - if got[0].Description == nil || *got[0].Description != "Rose Radishes" { 181 + if len(got) != 2 { 182 + t.Fatalf("expected 2 ingredients, got %d", len(got)) 183 + } 184 + if got[0].Description != "Pinot Tomatoes" { 168 185 t.Fatalf("unexpected description: %+v", got[0].Description) 169 186 } 170 187 } ··· 173 190 t.Parallel() 174 191 175 192 var sawRequest bool 193 + const skip = 17 176 194 httpClient := &http.Client{ 177 195 Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { 178 196 sawRequest = true 179 197 if got, want := r.URL.Host, "www.acmemarkets.com"; got != want { 180 198 t.Fatalf("unexpected host: got %q want %q", got, want) 181 199 } 200 + if got, want := r.URL.Query().Get("start"), "17"; got != want { 201 + t.Fatalf("unexpected start query param: got %q want %q", got, want) 202 + } 182 203 if got, want := r.Header.Get("Ocp-Apim-Subscription-Key"), "test-sub-key"; got != want { 183 204 t.Fatalf("unexpected subscription key: got %q want %q", got, want) 184 205 } ··· 207 228 return query.NewSearchClient(querycfg) 208 229 }) 209 230 210 - got, err := provider.GetIngredients(t.Context(), "acmemarkets_806", "pinot", 1) 231 + got, err := provider.GetIngredients(t.Context(), "acmemarkets_806", "pinot", skip) 211 232 if err != nil { 212 233 t.Fatalf("GetIngredients returned error: %v", err) 213 234 } 214 235 if !sawRequest { 215 236 t.Fatal("expected injected HTTP client to be used") 216 237 } 217 - if len(got) != 1 { 218 - t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 238 + if len(got) != 2 { 239 + t.Fatalf("expected 2 ingredients, got %d", len(got)) 219 240 } 220 - if got[0].Description == nil || *got[0].Description != "Rose" { 241 + if got[0].Description != "Pinot Noir" { 221 242 t.Fatalf("unexpected description: %+v", got[0].Description) 222 243 } 223 244 }
+48 -6
internal/kroger/staples.go
··· 7 7 "log/slog" 8 8 "net/http" 9 9 "slices" 10 + "strings" 10 11 12 + "careme/internal/ai" 11 13 "careme/internal/config" 12 14 "careme/internal/kroger/products" 13 15 "careme/internal/parallelism" ··· 59 61 return &StaplesProvider{client: client}, nil 60 62 } 61 63 62 - func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]Ingredient, error) { 63 - return parallelism.Flatten(defaultStaples(), func(category staplesFilter) ([]Ingredient, error) { 64 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 65 + return parallelism.Flatten(defaultStaples(), func(category staplesFilter) ([]ai.InputIngredient, error) { 64 66 ingredients, err := searchIngredients(ctx, p.client, locationID, category.Term, category.Brands, category.Frozen, 0) 65 67 if err != nil { 66 68 slog.WarnContext(ctx, "Failed to fetch category", "category", category.Term, "location", locationID, "error", err) 67 69 return nil, err 68 70 } 69 71 slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category.Term, "location", locationID) 70 - return ingredients, nil 72 + return lo.Map(ingredients, inputIngredientFromKrogerIngredient), nil 71 73 }) 72 74 } 73 75 74 - func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]Ingredient, error) { 75 - return searchIngredients(ctx, p.client, locationID, searchTerm, []string{"*"}, false, skip) 76 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 77 + ingredients, err := searchIngredients(ctx, p.client, locationID, searchTerm, []string{"*"}, false, skip) 78 + if err != nil { 79 + return nil, err 80 + } 81 + return lo.Map(ingredients, inputIngredientFromKrogerIngredient), nil 76 82 } 77 83 78 84 var availableInStore = products.Ais ··· 143 149 //Taxonomy: product., 144 150 // CountryOrigin: product.CountryOrigin, 145 151 // Favorite: item.Favorite, 146 - // InventoryStockLevel: item.InventoryStockLevel), 147 152 } 148 153 } 149 154 ··· 159 164 return ingredients, nil 160 165 } 161 166 167 + func inputIngredientFromKrogerIngredient(ingredient Ingredient, _ int) ai.InputIngredient { 168 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 169 + ProductID: strings.TrimSpace(toStr(ingredient.ProductId)), 170 + AisleNumber: strings.TrimSpace(toStr(ingredient.AisleNumber)), 171 + Brand: strings.TrimSpace(toStr(ingredient.Brand)), 172 + Description: strings.TrimSpace(toStr(ingredient.Description)), 173 + Size: strings.TrimSpace(toStr(ingredient.Size)), 174 + PriceRegular: clonePrice(ingredient.PriceRegular), 175 + PriceSale: clonePrice(ingredient.PriceSale), 176 + Categories: categoriesFromPtr(ingredient.Categories), 177 + }) 178 + } 179 + 180 + func categoriesFromPtr(ptr *[]string) []string { 181 + if ptr == nil { 182 + return nil 183 + } 184 + return append([]string(nil), (*ptr)...) 185 + } 186 + 187 + func clonePrice(price *float32) *float32 { 188 + if price == nil { 189 + return nil 190 + } 191 + value := *price 192 + return &value 193 + } 194 + 162 195 func defaultStaples() []staplesFilter { 163 196 return append(ProduceFilters(), []staplesFilter{ 164 197 { ··· 185 218 Term: "lamb", 186 219 Brands: []string{"Simple Truth"}, 187 220 }, 221 + { 222 + Term: "grains", 223 + Brands: []string{"*"}, 224 + }, 225 + { 226 + Term: "pasta", 227 + Brands: []string{"*"}, // Should we just put our thumb on the scale 228 + }, 229 + // TODO dairy, international 188 230 }...) 189 231 } 190 232
+37
internal/kroger/staples_test.go
··· 3 3 import ( 4 4 "net/http" 5 5 "net/http/httptest" 6 + "slices" 6 7 "strings" 7 8 "testing" 8 9 ··· 94 95 t.Fatalf("expected krogerError to include decoded payload, got %v", krogerError(http.StatusBadRequest, payload)) 95 96 } 96 97 } 98 + 99 + func TestInputIngredientFromKrogerIngredientMapsFields(t *testing.T) { 100 + regular := float32(4.99) 101 + sale := float32(3.49) 102 + categories := []string{"Produce", "Fresh Fruit"} 103 + ingredient := inputIngredientFromKrogerIngredient(Ingredient{ 104 + ProductId: stringPtr(" apple-1 "), 105 + AisleNumber: stringPtr(" 12 "), 106 + Brand: stringPtr(" Orchard Co "), 107 + Description: stringPtr(" Honeycrisp Apple "), 108 + Size: stringPtr(" 3 lb "), 109 + PriceRegular: &regular, 110 + PriceSale: &sale, 111 + Categories: &categories, 112 + }, 0) 113 + 114 + if ingredient.ProductID != "apple-1" { 115 + t.Fatalf("unexpected product id: %+v", ingredient) 116 + } 117 + if ingredient.AisleNumber != "12" || ingredient.Brand != "Orchard Co" || ingredient.Description != "Honeycrisp Apple" || ingredient.Size != "3 lb" { 118 + t.Fatalf("unexpected normalized ingredient: %+v", ingredient) 119 + } 120 + if ingredient.PriceRegular == nil || *ingredient.PriceRegular != regular { 121 + t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) 122 + } 123 + if ingredient.PriceSale == nil || *ingredient.PriceSale != sale { 124 + t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 125 + } 126 + if !slices.Equal(ingredient.Categories, categories) { 127 + t.Fatalf("unexpected categories: got %v want %v", ingredient.Categories, categories) 128 + } 129 + } 130 + 131 + func stringPtr(value string) *string { 132 + return &value 133 + }
+6 -2
internal/recipes/generator.go
··· 104 104 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "response_id", p.ResponseID) 105 105 instructions := regenerateInstructions(p) 106 106 107 + // TODO give them some sort of status. 107 108 shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ResponseID) 108 109 if err != nil { 109 110 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) ··· 129 130 if err != nil { 130 131 return nil, fmt.Errorf("failed to get staples: %w", err) 131 132 } 132 - g.writeStatus(ctx, hash, fmt.Sprintf("Looking through %d ingredients", len(ingredients))) 133 + ogCount := len(ingredients) 133 134 ingredients = lo.Filter(ingredients, func(ing ai.InputIngredient, _ int) bool { 134 135 // TODO make configurable? 135 - return ing.Grade == nil || ing.Grade.Score > 5 136 + return ing.Grade == nil || ing.Grade.Score > 6 136 137 }) 138 + // having category would be interesing here. 139 + g.writeStatus(ctx, hash, fmt.Sprintf("Considering %d out of %d ingredients", len(ingredients), ogCount)) 140 + 137 141 mutable.Shuffle(ingredients) 138 142 139 143 instructions := []string{p.Directive, p.Instructions}
+2 -2
internal/recipes/generator_hash_test.go
··· 23 23 } 24 24 25 25 // make sure we're intentional about breaking hash 26 - if h1 != "JjKXkKjKKpE" { 26 + if h1 != "wrxx3dmHzBA" { 27 27 t.Fatalf("expected hash to be stable and equal to JjKXkKjKKpE, got %s", h1) 28 28 } 29 29 ··· 31 31 if !ok { 32 32 t.Fatal("expected current hash passhed to legacy") 33 33 } 34 - if legacyHash != "cmVjaXBlJjKXkKjKKpE=" { 34 + if legacyHash != "cmVjaXBlwrxx3dmHzBA=" { 35 35 t.Fatalf("expected legacy hash to be base64 of recipe hash with prefix, got %s", legacyHash) 36 36 } 37 37
-1
internal/recipes/io.go
··· 108 108 } 109 109 }() 110 110 111 - // this should be back compat with kroger.Ingredient 112 111 var ingredients []ai.InputIngredient 113 112 if err := json.NewDecoder(ingredientBlob).Decode(&ingredients); err != nil { 114 113 return nil, err
-4
internal/recipes/io_test.go
··· 267 267 t.Fatalf("unexpected cached wine recommendation: got %q", got) 268 268 } 269 269 } 270 - 271 - func loPtr(v string) *string { 272 - return &v 273 - }
+2 -6
internal/recipes/server_test.go
··· 632 632 } 633 633 634 634 captured := generator.LastParams() 635 - if captured == nil { 636 - t.Fatal("expected captured params") 637 - } 635 + require.NotNil(t, captured) 638 636 if got, want := captured.LastRecipes, []string{"Cooked Recently"}; !slices.Equal(got, want) { 639 637 t.Fatalf("expected only recently cooked recipes in avoid list, got %v", got) 640 638 } ··· 1610 1608 } 1611 1609 1612 1610 captured := generator.LastParams() 1613 - if captured == nil { 1614 - t.Fatal("expected captured params") 1615 - } 1611 + require.NotNil(t, captured) 1616 1612 if got, want := captured.PriorSavedHashes, []string{alreadySaved.ComputeHash()}; !slices.Equal(got, want) { 1617 1613 t.Fatalf("expected prior saved hashes %v, got %v", want, got) 1618 1614 }
+36 -94
internal/recipes/staples.go
··· 25 25 "github.com/samber/lo" 26 26 ) 27 27 28 - // todo make this a indepenedent ingredient object not kroger. 29 - // we're cheating and making the wrapper here do the conversion for now but all underlying provider should create input ingredients 30 - // then this becomes staplesProvider 31 - type krogerProvider interface { 32 - FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 33 - GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) 34 - } 35 - 36 28 type identityProvider interface { 37 29 IsID(locationID string) bool 38 30 Signature() string ··· 40 32 41 33 type backendStaplesProvider interface { 42 34 identityProvider 43 - krogerProvider 35 + staplesProvider 44 36 } 45 37 46 38 type routingStaplesProvider struct { 47 39 backends []backendStaplesProvider 48 40 } 49 41 42 + type dedupingStaplesProvider struct { 43 + provider staplesProvider 44 + } 45 + 50 46 func NewStaplesProvider(cfg *config.Config) (staplesProvider, error) { 51 47 backends, err := defaultStaplesBackends(cfg) 52 48 if err != nil { 53 49 return nil, err 54 50 } 55 51 56 - return convertingProvider{routingStaplesProvider{ 52 + return dedupingStaplesProvider{provider: routingStaplesProvider{ 57 53 backends: backends, 58 54 }}, nil 59 55 } 60 56 61 - func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 57 + func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 62 58 provider, err := p.providerForLocation(locationID) 63 59 if err != nil { 64 60 return nil, err ··· 66 62 return provider.FetchStaples(ctx, locationID) 67 63 } 68 64 69 - func (p routingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 65 + func (p routingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 70 66 provider, err := p.providerForLocation(locationID) 71 67 if err != nil { 72 68 return nil, err ··· 74 70 return provider.GetIngredients(ctx, locationID, searchTerm, skip) 75 71 } 76 72 73 + func (p dedupingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 74 + ingredients, err := p.provider.FetchStaples(ctx, locationID) 75 + if err != nil { 76 + return nil, err 77 + } 78 + return dedupeInputIngredients(ingredients) 79 + } 80 + 81 + func (p dedupingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 82 + ingredients, err := p.provider.GetIngredients(ctx, locationID, searchTerm, skip) 83 + if err != nil { 84 + return nil, err 85 + } 86 + return dedupeInputIngredients(ingredients) 87 + } 88 + 77 89 type ingredientio interface { 78 90 SaveIngredients(ctx context.Context, hash string, ingredients []ai.InputIngredient) error 79 91 IngredientsFromCache(ctx context.Context, hash string) ([]ai.InputIngredient, error) ··· 94 106 GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) 95 107 } 96 108 97 - type convertingProvider struct { 98 - kprovider krogerProvider 99 - } 100 - 101 - var _ staplesProvider = convertingProvider{} 102 - 103 - func (cp convertingProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 104 - ingredients, err := cp.kprovider.FetchStaples(ctx, locationID) 105 - if err != nil { 106 - return nil, err 107 - } 108 - inputs := make([]ai.InputIngredient, 0, len(ingredients)) 109 + func dedupeInputIngredients(ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 110 + seen := map[string]bool{} 111 + var deduped []ai.InputIngredient 109 112 for _, ingredient := range ingredients { 110 - input, err := inputIngredientFromKrogerIngredient(ingredient) 111 - if err != nil { 112 - return nil, err 113 + if ingredient.ProductID == "" { 114 + return nil, fmt.Errorf("blank product id for ingredient: %+v", ingredient) 113 115 } 114 - inputs = append(inputs, input) 115 - } 116 - 117 - inputs = lo.UniqBy(inputs, func(i ai.InputIngredient) string { 118 - return i.ProductID 119 - }) 120 - return inputs, nil 121 - } 122 - 123 - func (cp convertingProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 124 - ingredients, err := cp.kprovider.GetIngredients(ctx, locationID, searchTerm, skip) 125 - if err != nil { 126 - return nil, err 127 - } 128 - inputs := make([]ai.InputIngredient, 0, len(ingredients)) 129 - for _, ingredient := range ingredients { 130 - input, err := inputIngredientFromKrogerIngredient(ingredient) 131 - if err != nil { 132 - return nil, err 116 + if seen[ingredient.ProductID] { 117 + continue 133 118 } 134 - inputs = append(inputs, input) 119 + seen[ingredient.ProductID] = true 120 + deduped = append(deduped, ingredient) 135 121 } 136 - 137 - inputs = lo.UniqBy(inputs, func(i ai.InputIngredient) string { 138 - return i.ProductID 139 - }) 140 - return inputs, nil 141 - } 142 - 143 - func inputIngredientFromKrogerIngredient(ingredient kroger.Ingredient) (ai.InputIngredient, error) { 144 - item := ai.InputIngredient{ 145 - ProductID: strings.TrimSpace(toStr(ingredient.ProductId)), 146 - AisleNumber: strings.TrimSpace(toStr(ingredient.AisleNumber)), 147 - Brand: strings.TrimSpace(toStr(ingredient.Brand)), 148 - Description: strings.TrimSpace(toStr(ingredient.Description)), 149 - Size: strings.TrimSpace(toStr(ingredient.Size)), 150 - PriceRegular: clonePrice(ingredient.PriceRegular), 151 - PriceSale: clonePrice(ingredient.PriceSale), 152 - Categories: categoriesFromPtr(ingredient.Categories), 153 - } 154 - item = ai.NormalizeInputIngredient(item) 155 - if item.ProductID == "" { 156 - return ai.InputIngredient{}, fmt.Errorf("ingredient product_id is required for %q", toStr(ingredient.Description)) 157 - } 158 - return item, nil 159 - } 160 - 161 - func toStr(ptr *string) string { 162 - if ptr == nil { 163 - return "" 164 - } 165 - return *ptr 166 - } 167 - 168 - func categoriesFromPtr(ptr *[]string) []string { 169 - if ptr == nil { 170 - return nil 171 - } 172 - return append([]string(nil), (*ptr)...) 173 - } 174 - 175 - func clonePrice(price *float32) *float32 { 176 - if price == nil { 177 - return nil 178 - } 179 - value := *price 180 - return &value 122 + return deduped, nil 181 123 } 182 124 183 125 func NewCachedStaplesService(cfg *config.Config, c cache.Cache, grader grader) (*cachedStaplesService, error) { ··· 312 254 return nil, fmt.Errorf("create albertsons staples provider: %w", err) 313 255 } 314 256 315 - krogerProvider, err := kroger.NewStaplesProvider(cfg) 257 + krogerBackend, err := kroger.NewStaplesProvider(cfg) 316 258 if err != nil { 317 259 return nil, fmt.Errorf("create kroger staples provider: %w", err) 318 260 } 319 261 320 262 return []backendStaplesProvider{ 321 263 albertsonsProvider, 322 - krogerProvider, 264 + krogerBackend, 323 265 // actowiz.NewStaplesProvider(), 324 266 walmart.NewStaplesProvider(), 325 267 wholefoods.NewStaplesProvider(wholefoods.NewClient(httpClient)),
+88 -62
internal/recipes/staples_test.go
··· 10 10 "careme/internal/ai" 11 11 "careme/internal/albertsons" 12 12 "careme/internal/cache" 13 - "careme/internal/kroger" 14 13 "careme/internal/locations" 15 14 ) 16 15 17 16 type stubStaplesProvider struct { 18 17 ids map[string]bool 19 - ingredients []kroger.Ingredient 18 + ingredients []ai.InputIngredient 20 19 err error 21 20 calls int 22 21 } ··· 29 28 return "stub-staples-v1" 30 29 } 31 30 32 - func (s *stubStaplesProvider) FetchStaples(_ context.Context, _ string) ([]kroger.Ingredient, error) { 31 + func (s *stubStaplesProvider) FetchStaples(_ context.Context, _ string) ([]ai.InputIngredient, error) { 33 32 s.calls++ 34 33 if s.err != nil { 35 34 return nil, s.err ··· 37 36 return slices.Clone(s.ingredients), nil 38 37 } 39 38 40 - func (s *stubStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]kroger.Ingredient, error) { 39 + func (s *stubStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]ai.InputIngredient, error) { 41 40 return s.FetchStaples(context.Background(), "") 42 41 } 43 42 ··· 86 85 } 87 86 88 87 func TestRoutingStaplesProvider_SelectsProviderByLocationID(t *testing.T) { 89 - krogerProvider := &stubStaplesProvider{ids: map[string]bool{"70100023": true}} 88 + krogerBackend := &stubStaplesProvider{ids: map[string]bool{"70100023": true}} 90 89 wholeFoodsProvider := &stubStaplesProvider{ids: map[string]bool{"wholefoods_10216": true}} 91 90 provider := routingStaplesProvider{ 92 - backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 91 + backends: []backendStaplesProvider{krogerBackend, wholeFoodsProvider}, 93 92 } 94 93 95 94 if _, err := provider.FetchStaples(t.Context(), "70100023"); err != nil { 96 95 t.Fatalf("FetchStaples kroger returned error: %v", err) 97 96 } 98 - if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 0 { 99 - t.Fatalf("expected kroger provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 97 + if krogerBackend.calls != 1 || wholeFoodsProvider.calls != 0 { 98 + t.Fatalf("expected kroger provider to be selected, got kroger=%d wholefoods=%d", krogerBackend.calls, wholeFoodsProvider.calls) 100 99 } 101 100 102 101 if _, err := provider.FetchStaples(t.Context(), "wholefoods_10216"); err != nil { 103 102 t.Fatalf("FetchStaples whole foods returned error: %v", err) 104 103 } 105 - if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 1 { 106 - t.Fatalf("expected whole foods provider to be selected once, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 104 + if krogerBackend.calls != 1 || wholeFoodsProvider.calls != 1 { 105 + t.Fatalf("expected whole foods provider to be selected once, got kroger=%d wholefoods=%d", krogerBackend.calls, wholeFoodsProvider.calls) 107 106 } 108 107 } 109 108 ··· 125 124 } 126 125 127 126 func TestRoutingStaplesProvider_GetIngredients_SelectsProviderByLocationID(t *testing.T) { 128 - krogerProvider := &stubStaplesProvider{ 127 + krogerBackend := &stubStaplesProvider{ 129 128 ids: map[string]bool{"70100023": true}, 130 - ingredients: []kroger.Ingredient{{Description: loPtr("Pinot Noir")}}, 129 + ingredients: []ai.InputIngredient{{ProductID: "1", Description: "Pinot Noir"}}, 131 130 } 132 131 wholeFoodsProvider := &stubStaplesProvider{ 133 132 ids: map[string]bool{"wholefoods_10216": true}, 134 - ingredients: []kroger.Ingredient{{Description: loPtr("Whole Foods Pinot Noir")}}, 133 + ingredients: []ai.InputIngredient{{ProductID: "2", Description: "Whole Foods Pinot Noir"}}, 135 134 } 136 135 provider := routingStaplesProvider{ 137 - backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 136 + backends: []backendStaplesProvider{krogerBackend, wholeFoodsProvider}, 138 137 } 139 138 140 139 got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 0) 141 140 if err != nil { 142 141 t.Fatalf("GetIngredients returned error: %v", err) 143 142 } 144 - if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Whole Foods Pinot Noir" { 143 + if len(got) != 1 || got[0].Description != "Whole Foods Pinot Noir" { 145 144 t.Fatalf("unexpected ingredients: %+v", got) 146 145 } 147 - if krogerProvider.calls != 0 || wholeFoodsProvider.calls != 1 { 148 - t.Fatalf("expected whole foods provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 146 + if krogerBackend.calls != 0 || wholeFoodsProvider.calls != 1 { 147 + t.Fatalf("expected whole foods provider to be selected, got kroger=%d wholefoods=%d", krogerBackend.calls, wholeFoodsProvider.calls) 149 148 } 150 149 } 151 150 152 - func TestInputIngredientFromKrogerIngredientMapsFields(t *testing.T) { 153 - regular := float32(4.99) 154 - sale := float32(3.49) 155 - categories := []string{"Produce", "Fresh Fruit"} 156 - ingredient, err := inputIngredientFromKrogerIngredient(kroger.Ingredient{ 157 - ProductId: loPtr(" apple-1 "), 158 - AisleNumber: loPtr(" 12 "), 159 - Brand: loPtr(" Orchard Co "), 160 - Description: loPtr(" Honeycrisp Apple "), 161 - Size: loPtr(" 3 lb "), 162 - PriceRegular: &regular, 163 - PriceSale: &sale, 164 - Categories: &categories, 165 - }) 151 + func TestRoutingStaplesProvider_DoesNotDedupeIngredients(t *testing.T) { 152 + backend := &stubStaplesProvider{ 153 + ids: map[string]bool{"70100023": true}, 154 + ingredients: []ai.InputIngredient{ 155 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 156 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 157 + }, 158 + } 159 + provider := routingStaplesProvider{ 160 + backends: []backendStaplesProvider{backend}, 161 + } 162 + 163 + got, err := provider.FetchStaples(t.Context(), "70100023") 166 164 if err != nil { 167 - t.Fatalf("inputIngredientFromKrogerIngredient returned error: %v", err) 165 + t.Fatalf("FetchStaples returned error: %v", err) 166 + } 167 + if len(got) != 2 { 168 + t.Fatalf("expected routing provider to preserve backend ingredients, got %+v", got) 168 169 } 170 + } 169 171 170 - if ingredient.ProductID != "apple-1" { 171 - t.Fatalf("unexpected product id: %+v", ingredient) 172 + func TestDedupingStaplesProvider_FetchStaplesDedupesProductIDs(t *testing.T) { 173 + provider := dedupingStaplesProvider{ 174 + provider: &stubRoutingStaplesProvider{ 175 + ingredients: []ai.InputIngredient{ 176 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 177 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 178 + {ProductID: "spinach-1", Description: "Baby Spinach"}, 179 + }, 180 + }, 172 181 } 173 - if ingredient.AisleNumber != "12" || ingredient.Brand != "Orchard Co" || ingredient.Description != "Honeycrisp Apple" || ingredient.Size != "3 lb" { 174 - t.Fatalf("unexpected normalized ingredient: %+v", ingredient) 182 + 183 + got, err := provider.FetchStaples(t.Context(), "70100023") 184 + if err != nil { 185 + t.Fatalf("FetchStaples returned error: %v", err) 175 186 } 176 - if ingredient.PriceRegular == nil || *ingredient.PriceRegular != regular { 177 - t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) 187 + if len(got) != 2 { 188 + t.Fatalf("expected deduped ingredients, got %+v", got) 178 189 } 179 - if ingredient.PriceSale == nil || *ingredient.PriceSale != sale { 180 - t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 190 + if got[0].ProductID != "apple-1" || got[1].ProductID != "spinach-1" { 191 + t.Fatalf("expected first occurrence of each product id in order, got %+v", got) 181 192 } 182 - if !slices.Equal(ingredient.Categories, categories) { 183 - t.Fatalf("unexpected categories: got %v want %v", ingredient.Categories, categories) 193 + } 194 + 195 + func TestDedupingStaplesProvider_GetIngredientsDedupesProductIDs(t *testing.T) { 196 + provider := dedupingStaplesProvider{ 197 + provider: &stubRoutingStaplesProvider{ 198 + ingredients: []ai.InputIngredient{ 199 + {ProductID: "wine-1", Description: "Pinot Noir"}, 200 + {ProductID: "wine-1", Description: "Pinot Noir"}, 201 + {ProductID: "wine-2", Description: "Cabernet Sauvignon"}, 202 + }, 203 + }, 204 + } 205 + 206 + got, err := provider.GetIngredients(t.Context(), "70100023", "pinot noir", 0) 207 + if err != nil { 208 + t.Fatalf("GetIngredients returned error: %v", err) 209 + } 210 + if len(got) != 2 { 211 + t.Fatalf("expected deduped ingredients, got %+v", got) 212 + } 213 + if got[0].ProductID != "wine-1" || got[1].ProductID != "wine-2" { 214 + t.Fatalf("expected first occurrence of each product id in order, got %+v", got) 184 215 } 185 216 } 186 217 187 - func TestInputIngredientFromKrogerIngredientRejectsBlankProductID(t *testing.T) { 188 - _, err := inputIngredientFromKrogerIngredient(kroger.Ingredient{Description: loPtr("Asparagus")}) 218 + func TestDedupingStaplesProvider_RejectsBlankProductID(t *testing.T) { 219 + provider := dedupingStaplesProvider{ 220 + provider: &stubRoutingStaplesProvider{ 221 + ingredients: []ai.InputIngredient{{Description: "Mystery Ingredient"}}, 222 + }, 223 + } 224 + 225 + _, err := provider.FetchStaples(t.Context(), "70100023") 189 226 if err == nil { 190 227 t.Fatal("expected blank product id error") 191 228 } 192 - if !strings.Contains(err.Error(), "product_id is required") { 229 + if !strings.Contains(err.Error(), "blank product id for ingredient") { 193 230 t.Fatalf("unexpected error: %v", err) 194 231 } 195 232 } ··· 208 245 cacheStore := cache.NewFileCache(t.TempDir()) 209 246 provider := &stubStaplesProvider{ 210 247 ids: map[string]bool{"wholefoods_10216": true}, 211 - ingredients: []kroger.Ingredient{ 212 - {ProductId: loPtr("apple-1"), Description: loPtr("Honeycrisp Apple")}, 213 - {ProductId: loPtr("apple-1"), Description: loPtr("Honeycrisp Apple")}, 214 - {ProductId: loPtr("spinach-1"), Description: loPtr("Baby Spinach")}, 248 + ingredients: []ai.InputIngredient{ 249 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 250 + {ProductID: "apple-1", Description: "Honeycrisp Apple"}, 251 + {ProductID: "spinach-1", Description: "Baby Spinach"}, 215 252 }, 216 253 } 217 254 s := &cachedStaplesService{ 218 255 cache: IO(cacheStore), 219 - provider: convertingProvider{kprovider: provider}, 256 + provider: provider, 220 257 grader: &stubIngredientGrader{}, 221 258 } 222 259 ··· 232 269 if provider.calls != 1 { 233 270 t.Fatalf("expected provider to be called once, got %d", provider.calls) 234 271 } 235 - if len(got) != 2 { 236 - t.Fatalf("expected deduped results, got %d", len(got)) 237 - } 238 272 if got[0].ProductID == "" { 239 273 t.Fatalf("expected input ingredient product id, got %+v", got) 240 274 } 241 275 242 - cached, err := IO(cacheStore).IngredientsFromCache(t.Context(), params.LocationHash()) 243 - if err != nil { 244 - t.Fatalf("IngredientsFromCache returned error: %v", err) 245 - } 246 - if len(cached) != 2 { 247 - t.Fatalf("expected cached deduped results, got %d", len(cached)) 248 - } 249 - 250 276 gotAgain, err := s.FetchStaples(t.Context(), params) 251 277 if err != nil { 252 278 t.Fatalf("FetchStaples returned error on cached call: %v", err) ··· 254 280 if provider.calls != 1 { 255 281 t.Fatalf("expected cached call to skip provider, got %d calls", provider.calls) 256 282 } 257 - if len(gotAgain) != 2 { 283 + if len(gotAgain) != 3 { 258 284 t.Fatalf("expected cached results, got %d", len(gotAgain)) 259 285 } 260 286 }
+1
internal/templates/about.go
··· 77 77 {Comment: "half eaten lamb chopps", ImageID: "AP1GczOMjRr7tmZ5xxPzXsUHOip34t32QHfZJrQFsj6bo_FeU3P38DoRsHP-iAqxZMgj_WPE3XiJRMpDM6ezi8f8Q1pd1d92EnFacQF-vkgy6qv2ULgct8qh", RecipeHash: "cbycxIAij_RK6vD4BfptFQ=="}, 78 78 {Comment: "chicken thights fennel and carrots", ImageID: "AP1GczO4qkM5zaXym17qH8Cy2IpWW_SHdWDmkKMiRx_VcN4ZBG9_dwI3ybDdri2v8n9XFNdCprnv72kD2JCwMnSkz38Mqa95OORDDjppLMGimj0DLbQATOf3", RecipeHash: "7AvK-N9pE6lJY0S40JK23A=="}, 79 79 {Comment: "sausage, mushrooms and kale pasta", ImageID: "AP1GczMxEf7tY7cpxOuJnHlzJw48xq-JtP_x5XVjNCzs8m_a6HuizPEVgjWKsuVs84WNwa181arukeILhn32Lx6u_XwjDamwRUMnVylxChG8i7K_-fK56ztG", RecipeHash: "HZsnsGnH739VEKUrE18KGg=="}, 80 + {Comment: "Tomato Basil Salmon and Sweet Potato Mash", ImageID: "AP1GczNEyUDLKFjvq-MkU1NXbNTrLss1qybDKLggOjzz96uSZ3MeCJloup4WSCQnx_1OEYPeZIzyHDvZJTKngMVsAw23z5Qwu3o1sdQ2iClGgdtqA05E9TTF", RecipeHash: "bqkbPEAEaKH-P0IgDogzcQ=="}, 80 81 }
+3 -3
internal/walmart/staples.go
··· 5 5 "fmt" 6 6 "strings" 7 7 8 - "careme/internal/kroger" 8 + "careme/internal/ai" 9 9 ) 10 10 11 11 const UnsupportedStaplesSignature = "unsupported-staples-v1" ··· 48 48 return UnsupportedStaplesSignature 49 49 } 50 50 51 - func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]kroger.Ingredient, error) { 51 + func (p StaplesProvider) FetchStaples(_ context.Context, locationID string) ([]ai.InputIngredient, error) { 52 52 return nil, fmt.Errorf("staples provider does not support location %q", locationID) 53 53 } 54 54 55 - func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 55 + func (p StaplesProvider) GetIngredients(_ context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 56 56 return nil, fmt.Errorf("ingredient search is not supported for location %q and term %q", locationID, searchTerm) 57 57 }
+16 -29
internal/wholefoods/staples.go
··· 9 9 "log/slog" 10 10 "strings" 11 11 12 - "careme/internal/kroger" 12 + "careme/internal/ai" 13 13 "careme/internal/parallelism" 14 14 15 15 "github.com/samber/lo" ··· 45 45 return ok 46 46 } 47 47 48 - func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 48 + func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 49 49 if p.client == nil { 50 50 return nil, fmt.Errorf("whole foods client is required") 51 51 } ··· 56 56 return nil, fmt.Errorf("invalid whole foods location id %q", locationID) 57 57 } 58 58 59 - return parallelism.Flatten(defaultStaples(), func(category string) ([]kroger.Ingredient, error) { 59 + return parallelism.Flatten(defaultStaples(), func(category string) ([]ai.InputIngredient, error) { 60 60 resp, err := p.client.Category(ctx, category, storeID) 61 61 if err != nil { 62 62 slog.WarnContext(ctx, "Failed to fetch category", "category", category, "location", locationID, "error", err) 63 63 return nil, err 64 64 } 65 65 66 - ingredients := lo.Map(resp, func(product product, _ int) kroger.Ingredient { 67 - return productToIngredient(product) 68 - }) 66 + ingredients := lo.Map(resp, productToIngredient) 69 67 slog.InfoContext(ctx, "Found ingredients for category", "count", len(ingredients), "category", category, "location", locationID) 70 68 71 69 return ingredients, nil 72 70 }) 73 71 } 74 72 75 - func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) { 73 + func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, _ int) ([]ai.InputIngredient, error) { 76 74 if p.client == nil { 77 75 return nil, fmt.Errorf("whole foods client is required") 78 76 } ··· 82 80 return nil, fmt.Errorf("invalid whole foods location id %q", locationID) 83 81 } 84 82 83 + // no pagination so no skip 85 84 resp, err := p.client.Category(ctx, searchTerm, storeID) 86 85 if err != nil { 87 86 return nil, err 88 87 } 89 88 90 - ingredients := lo.Map(resp, func(product product, _ int) kroger.Ingredient { 91 - return productToIngredient(product) 92 - }) 93 - if skip >= len(ingredients) { 94 - return []kroger.Ingredient{}, nil 95 - } 96 - return ingredients[skip:], nil 89 + return lo.Map(resp, productToIngredient), nil 97 90 } 98 91 99 92 func defaultStaples() []string { ··· 108 101 "shellfish", 109 102 "goat-lamb-veal", 110 103 "game-meats", 104 + "rice-grains", 105 + "pasta-noodles", 111 106 } 112 - // rice-grains? 113 - // pasta-noodles 114 107 // red-wine, white-wine, sparkling 115 108 } 116 109 117 - func productToIngredient(product product) kroger.Ingredient { 110 + func productToIngredient(product product, _ int) ai.InputIngredient { 118 111 var regularPrice *float32 119 112 if product.RegularPrice > 0 { 120 113 price := float32(product.RegularPrice) ··· 138 131 // categories := compactStrings(localCategory(product)) 139 132 140 133 hasher := fnv.New32a() 134 + // dupes for different units of measure? 141 135 _ = lo.Must(hasher.Write([]byte(product.Slug))) 142 136 productId := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) 143 - return kroger.Ingredient{ 144 - ProductId: stringPtr(productId), 145 - Brand: stringPtr(strings.TrimSpace(product.Brand)), 146 - Description: stringPtr(strings.TrimSpace(product.Name)), 137 + return ai.NormalizeInputIngredient(ai.InputIngredient{ 138 + ProductID: productId, 139 + Brand: strings.TrimSpace(product.Brand), 140 + Description: strings.TrimSpace(product.Name), 147 141 // Size: size, 148 142 PriceRegular: regularPrice, 149 143 PriceSale: salePrice, 150 144 // / Categories: slicePtr(categories), 151 - } 152 - } 153 - 154 - func stringPtr(value string) *string { 155 - if value == "" { 156 - return nil 157 - } 158 - return &value 145 + }) 159 146 }
+8 -8
internal/wholefoods/staples_test.go
··· 79 79 } 80 80 81 81 ingredient := got[0] 82 - if ingredient.Description == nil || *ingredient.Description != "Organic Asparagus" { 82 + if ingredient.Description != "Organic Asparagus" { 83 83 t.Fatalf("unexpected description: %+v", ingredient.Description) 84 84 } 85 - if ingredient.Brand == nil || *ingredient.Brand != "Whole Foods Market" { 85 + if ingredient.Brand != "Whole Foods Market" { 86 86 t.Fatalf("unexpected brand: %+v", ingredient.Brand) 87 87 } 88 - if ingredient.ProductId == nil || *ingredient.ProductId != "odQxPA" { 89 - t.Fatalf("unexpected product id: %+v", *ingredient.ProductId) 88 + if ingredient.ProductID != "odQxPA" { 89 + t.Fatalf("unexpected product id: %+v", ingredient.ProductID) 90 90 } 91 91 if ingredient.PriceRegular == nil || *ingredient.PriceRegular != float32(5.99) { 92 92 t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) ··· 122 122 } 123 123 provider := NewStaplesProvider(client) 124 124 125 - got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 1) 125 + got, err := provider.GetIngredients(t.Context(), "wholefoods_10216", "pinot noir", 0) 126 126 if err != nil { 127 127 t.Fatalf("GetIngredients returned error: %v", err) 128 128 } 129 - if len(got) != 1 { 130 - t.Fatalf("expected 1 ingredient after skip, got %d", len(got)) 129 + if len(got) != 2 { 130 + t.Fatalf("expected 2 ingredients, got %d", len(got)) 131 131 } 132 - if got[0].Description == nil || *got[0].Description != "Rose" { 132 + if got[0].Description != "Pinot Noir" { 133 133 t.Fatalf("unexpected ingredient description: %+v", got[0].Description) 134 134 } 135 135 if got := client.callCount(); got != 1 {