ai cooking
0
fork

Configure Feed

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

ingredient grader (#519)

* ingredient grader

* some futzing around

* okay simplified down some

* aggressive

* closer

* pasing lets see if we got any better

* working

* okay we got something

* review trimming

* missed some

* try and simplify

* cacheversion

* goodbye categories

* ugh

* passing

* move conversion closer to what calls it

* okay got it

* yuck

* thoss out <= 5 grades

* unused

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
fe616d6e 40cfe7f5

+1585 -352
+5 -2
cmd/careme/web.go
··· 16 16 "careme/internal/auth" 17 17 "careme/internal/config" 18 18 "careme/internal/ingredients" 19 + ingredientgrading "careme/internal/ingredients/grading" 19 20 "careme/internal/locations" 20 21 "careme/internal/recipes" 21 22 "careme/internal/recipes/critique" ··· 57 58 userStorage := users.NewStorage(cache) 58 59 ro := &readyOnce{} 59 60 watchdogServer := watchdog.Server{} 61 + // TODO make the mock more transparent? 62 + grader := ingredientgrading.NewManager(cfg, cache) 60 63 61 64 var generator recipes.ExtGenerator 62 65 var waitFns []func() 63 - 64 66 if cfg.Mocks.Enable { 65 67 generator = recipes.NewMockGenerator() 66 68 } else { 67 69 mc := critique.NewManager(cfg, cache) 68 70 ro.add(mc) 71 + 69 72 aiclient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 70 73 ro.add(aiclient) 71 - staples, err := recipes.NewCachedStaplesService(cfg, cache) 74 + staples, err := recipes.NewCachedStaplesService(cfg, cache, grader) 72 75 if err != nil { 73 76 return fmt.Errorf("failed to create staples service: %w", err) 74 77 }
+56 -11
cmd/ingredients/main.go
··· 5 5 "flag" 6 6 "fmt" 7 7 "log" 8 + "slices" 9 + "strings" 8 10 11 + "careme/internal/ai" 12 + "careme/internal/cache" 9 13 "careme/internal/config" 10 - "careme/internal/kroger" 14 + ingredientgrading "careme/internal/ingredients/grading" 11 15 "careme/internal/recipes" 16 + 17 + "github.com/samber/lo" 12 18 ) 13 19 14 20 func main() { ··· 31 37 log.Fatalf("failed to create recipe generator: %s", err) 32 38 } 33 39 34 - var ings []kroger.Ingredient 40 + var ings []ai.InputIngredient 35 41 if searchTerm == "" { 36 42 ings, err = sp.FetchStaples(ctx, location) 37 43 } else { ··· 42 48 } 43 49 44 50 catMap := make(map[string]int) 51 + if cfg.IngredientGrading.Enable { 52 + log.Printf("Grading %d ingredients", len(ings)) 53 + cacheStore, err := cache.MakeCache() 54 + if err != nil { 55 + log.Fatalf("failed to create cache for ingredient grading: %s", err) 56 + } 57 + grader := ingredientgrading.NewManager(cfg, cacheStore) 58 + graded, err := grader.GradeIngredients(ctx, ings) 59 + if err != nil { 60 + log.Fatalf("failed to grade ingredients: %s", err) 61 + } 62 + slices.SortFunc(graded, func(a, b ai.InputIngredient) int { 63 + if a.Grade.Score != b.Grade.Score { 64 + return b.Grade.Score - a.Grade.Score 65 + } 66 + return strings.Compare(strings.ToLower(a.Description), strings.ToLower(b.Description)) 67 + }) 68 + for _, result := range graded { 69 + for _, cat := range result.Categories { 70 + catMap[cat] += 1 71 + } 72 + 73 + fmt.Printf("%2d/10: %s - %s: size: %s: %s\n", result.Grade.Score, result.Brand, result.Description, result.Size, result.Grade.Reason) 74 + } 75 + for cat, count := range catMap { 76 + fmt.Printf("Category: %s, Count: %d\n", cat, count) 77 + } 78 + 79 + counts := lo.Reduce(graded, func(counts map[int]int, ingredient ai.InputIngredient, _ int) map[int]int { 80 + counts[ingredient.Grade.Score] += 1 81 + return counts 82 + }, make(map[int]int)) 83 + fmt.Println("Grade distribution:") 84 + for score := 0; score <= 10; score++ { 85 + fmt.Printf("Score %2d: %d ingredients\n", score, counts[score]) 86 + } 87 + return 88 + } 89 + 45 90 for _, i := range ings { 46 - for _, cat := range *i.Categories { 91 + for _, cat := range categories(i) { 47 92 catMap[cat] += 1 48 93 } 49 - fmt.Printf("%s: %s - %s:($%s) size: %s categories: %v\n", toString(i.ProductId), toString(i.Brand), toString(i.Description), toFloat(i.PriceRegular), toString(i.Size), i.Categories) 94 + fmt.Printf("%s: %s - %s:($%s) size: %s categories: %v\n", i.ProductID, i.Brand, i.Description, toFloat(i.PriceRegular), i.Size, i.Categories) 50 95 } 51 96 for cat, count := range catMap { 52 97 fmt.Printf("Category: %s, Count: %d\n", cat, count) 53 98 } 54 99 } 55 100 56 - func toString(s *string) string { 57 - if s == nil { 58 - return "" 59 - } 60 - return *s 61 - } 62 - 63 101 func toFloat(f *float32) string { 64 102 if f == nil { 65 103 return "" 66 104 } 67 105 return fmt.Sprintf("%.2f", *f) 68 106 } 107 + 108 + func categories(i ai.InputIngredient) []string { 109 + if i.Categories == nil { 110 + return nil 111 + } 112 + return i.Categories 113 + }
+10 -25
cmd/producecheck/main.go
··· 9 9 "strings" 10 10 "unicode" 11 11 12 + "careme/internal/ai" 12 13 "careme/internal/config" 13 - "careme/internal/kroger" 14 14 "careme/internal/recipes" 15 15 16 - "github.com/samber/lo" 17 16 "golang.org/x/text/unicode/norm" 18 17 ) 19 - 20 - type staplesProvider interface { 21 - FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 22 - } 23 18 24 19 func main() { 25 20 var locationID string ··· 90 85 matchedDescriptions []string 91 86 } 92 87 88 + type staplesProvider interface { 89 + FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) 90 + } 91 + 93 92 func checkProduceAvailability(ctx context.Context, client staplesProvider, locationID string, produce []string) ([]string, int, error) { 94 93 // todo check total number of queries. 95 94 ··· 106 105 matchedDescriptions: matchedDescriptions, 107 106 } 108 107 109 - ingredients = lo.UniqBy(ingredients, func(i kroger.Ingredient) string { 110 - return toString(i.ProductId) 111 - }) 112 - 113 108 // TODO have staples return subset of ingredient that can say the search term it go a match on 114 109 // then we can give info on whats coming from what queries. Could use categories for wholefoods. 115 110 // annotateUniqueOnlyMatches(stats) ··· 118 113 return evaluateProduceAvailability(produce, ingredients), len(ingredients), nil 119 114 } 120 115 121 - func toString(s *string) string { 122 - if s == nil { 123 - return "" 124 - } 125 - return *s 126 - } 127 - 128 - func evaluateProduceAvailability(produce []string, ingredients []kroger.Ingredient) []string { 116 + func evaluateProduceAvailability(produce []string, ingredients []ai.InputIngredient) []string { 129 117 missing := make([]string, 0) 130 118 for _, term := range produce { 131 119 matches := hasProduce(ingredients, term) ··· 141 129 return missing 142 130 } 143 131 144 - func summarizeFilterMatches(produce []string, ingredients []kroger.Ingredient) (int, int) { 132 + func summarizeFilterMatches(produce []string, ingredients []ai.InputIngredient) (int, int) { 145 133 matchedTerms, matchedProducts, _ := summarizeFilterMatchesDetailed(produce, ingredients) 146 134 return matchedTerms, matchedProducts 147 135 } 148 136 149 - func summarizeFilterMatchesDetailed(produce []string, ingredients []kroger.Ingredient) (int, int, []string) { 137 + func summarizeFilterMatchesDetailed(produce []string, ingredients []ai.InputIngredient) (int, int, []string) { 150 138 matchedTerms := 0 151 139 matchedProducts := 0 152 140 descriptions := make(map[string]struct{}) ··· 201 189 fmt.Println() 202 190 } 203 191 204 - func hasProduce(ingredients []kroger.Ingredient, term string) []string { 192 + func hasProduce(ingredients []ai.InputIngredient, term string) []string { 205 193 needleTokens := tokens(normalizeTerm(term)) 206 194 if len(needleTokens) == 0 { 207 195 return nil ··· 209 197 seen := make(map[string]struct{}) 210 198 matches := make([]string, 0) 211 199 for _, ingredient := range ingredients { 212 - if ingredient.Description == nil { 213 - continue 214 - } 215 - description := strings.TrimSpace(*ingredient.Description) 200 + description := strings.TrimSpace(ingredient.Description) 216 201 if description == "" { 217 202 continue 218 203 }
+15 -19
cmd/producecheck/main_test.go
··· 4 4 "reflect" 5 5 "testing" 6 6 7 - "careme/internal/kroger" 7 + "careme/internal/ai" 8 8 ) 9 9 10 10 func TestParseProduceList(t *testing.T) { ··· 52 52 } 53 53 54 54 func TestHasProduce_UsesTokenMatching(t *testing.T) { 55 - descriptions := []*string{ 56 - strPtr("Fresh Seedless Mini Cucumbers"), 57 - strPtr("Fresh Jalapeno Peppers"), 58 - strPtr("Simple Truth Organic® Whole Baby Bella Mushrooms"), 59 - strPtr("Simple Truth Organic® Kiwifruit"), 55 + descriptions := []string{ 56 + "Fresh Seedless Mini Cucumbers", 57 + "Fresh Jalapeno Peppers", 58 + "Simple Truth Organic® Whole Baby Bella Mushrooms", 59 + "Simple Truth Organic® Kiwifruit", 60 60 } 61 - ingredients := make([]kroger.Ingredient, 0, len(descriptions)) 61 + ingredients := make([]ai.InputIngredient, 0, len(descriptions)) 62 62 for _, d := range descriptions { 63 - ingredients = append(ingredients, kroger.Ingredient{Description: d}) 63 + ingredients = append(ingredients, ai.InputIngredient{Description: d}) 64 64 } 65 65 66 66 tests := []struct { ··· 81 81 } 82 82 83 83 func TestSummarizeFilterMatches(t *testing.T) { 84 - descriptions := []*string{ 85 - strPtr("Fresh Seedless Mini Cucumbers"), 86 - strPtr("Fresh Mini Cucumbers"), 87 - strPtr("Fresh Jalapeno Peppers"), 88 - strPtr("Simple Truth Organic® Kiwifruit"), 84 + descriptions := []string{ 85 + "Fresh Seedless Mini Cucumbers", 86 + "Fresh Mini Cucumbers", 87 + "Fresh Jalapeno Peppers", 88 + "Simple Truth Organic® Kiwifruit", 89 89 } 90 - ingredients := make([]kroger.Ingredient, 0, len(descriptions)) 90 + ingredients := make([]ai.InputIngredient, 0, len(descriptions)) 91 91 for _, d := range descriptions { 92 - ingredients = append(ingredients, kroger.Ingredient{Description: d}) 92 + ingredients = append(ingredients, ai.InputIngredient{Description: d}) 93 93 } 94 94 95 95 produce := []string{ ··· 136 136 t.Fatalf("stats[2].UniqueOnlyMatches = %d, want %d", stats[2].UniqueOnlyMatches, 1) 137 137 } 138 138 }*/ 139 - 140 - func strPtr(s string) *string { 141 - return &s 142 - }
+9 -8
docs/cache-layout.md
··· 27 27 | Prefix | Stored value | Written by | Read by | 28 28 | --- | --- | --- | --- | 29 29 | `shoppinglist/` | JSON `ai.ShoppingList` keyed by shopping hash | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`FromCache`) | 30 - | `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`) | 30 + | `ingredients/` | JSON `[]ai.InputIngredient` keyed by location hash for staple caches, or JSON `[]kroger.Ingredient` keyed by wine style/date/location hash for wine candidate caches | `internal/recipes/io.go` (`SaveInputIngredients`, `SaveIngredients`) via `internal/recipes/generator.go` (`FetchStaples`, `PickAWine`) | `internal/recipes/io.go` (`InputIngredientsFromCache`, `IngredientsFromCache`) via `internal/recipes/generator.go` (`FetchStaples`, `PickAWine`) and `internal/ingredients/server.go` | 31 31 | `params/` | JSON `generatorParams` keyed by shopping hash; params no longer embed the resolved staple filter list | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 32 32 | `generation_status/` | JSON `recipes.GenerationStatus` (`stage`, `message`, `updated_at`) keyed by shopping hash for spinner progress | `internal/recipes/generation_status.go` (`SaveGenerationStatus`) via `internal/recipes/server.go` (`kickgeneration`) and `internal/recipes/generator.go` (`GenerateRecipes`) | `internal/recipes/generation_status.go` (`GenerationStatusFromCache`) via `internal/recipes/server.go` (`Spin`) | 33 33 | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`SingleFromCache`) | ··· 37 37 | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | 38 38 | `recipe_feedback/` | JSON `feedback.Feedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) using `internal/recipes/feedback/model.go` (`Marshal`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) using `internal/recipes/feedback/model.go` (`Decode`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 39 39 | `recipe_critiques/` | JSON `ai.RecipeCritique` (`schema_version`, `overall_score`, `summary`, `strengths`, `issues`, `suggested_fixes`, `model`, `critiqued_at`) per recipe hash | `internal/recipes/critique.go` (`SaveCritique`) via `internal/recipes/generator.go` (`GenerateRecipes`) after OpenAI recipe generation/regeneration | `internal/recipes/critique.go` (`CritiqueFromCache`) for internal analysis and future tuning workflows | 40 + | `ingredient_grades/` | JSON `ai.InputIngredient` with embedded `grade` (`score`, `reason`) keyed by `<cache_version>/<ingredient_hash>` | `internal/ingredients/grading/store.go` (`Save`) via `internal/ingredients/grading/cache.go` (`GradeIngredients`) during recipe ingredient prioritization and admin inspection | `internal/ingredients/grading/store.go` (`Load`) via `internal/ingredients/grading/cache.go` (`GradeIngredients`) and `internal/ingredients/server.go` (`GET /ingredients/{hash}/graded`) | 40 41 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) | 41 42 | `email2user/` | Plain text user ID keyed by normalized email | `internal/users/storage.go` (`FindOrCreateFromClerk`) | `internal/users/storage.go` (`GetByEmail`) | 42 43 | `location-store-requests/` | JSON `{store_id, zip, requested_at}` for stores present in location search but not yet supported for staples | `internal/locations/locations.go` (`POST /locations/request-store`) | `internal/locations/locations.go` (`RequestedStoreIDs`) and operational triage from shared cache/blob storage | ··· 72 73 - Publix uses a separate cache created via `cache.EnsureCache("publix")`; it does not share the `recipes` container/directory. 73 74 - Recipe images use a separate cache created via `cache.EnsureCache("recipe-images")`; they do not share the main `recipes` container/directory. 74 75 - Whole Foods uses a separate cache created via `cache.EnsureCache("wholefoods")`; it does not share the `recipes` container/directory. 75 - - Local cache paths when filesystem backend is used. are 76 - - `recipes/` for most app data, 76 + - Local cache paths when filesystem backend is used. are 77 + - `recipes/` for most app data, 77 78 - `recipe-images/` for recipe images, 78 - - `aldi/` for ALDI data, 79 - - `albertsons/` for Albertsons-family data, 80 - - `heb/` for HEB data, 81 - - `publix/` for Publix data, 79 + - `aldi/` for ALDI data, 80 + - `albertsons/` for Albertsons-family data, 81 + - `heb/` for HEB data, 82 + - `publix/` for Publix data, 82 83 - `wegmans/` for Wegmans data 83 - - `wholefoods/` for Whole Foods data 84 + - `wholefoods/` for Whole Foods data 84 85 - Blob names in Azure match the same key strings listed above inside their respective containers. 85 86 - Staple `ingredients/` cache keys derive from location ID, date, and a versioned backend staple signature (for example `kroger-staples-v1` or `wholefoods-staples-v1`), so Kroger and Whole Foods locations do not share staple caches and staple-definition changes can invalidate caches intentionally. 86 87 - Recipe image cache keys are stable per recipe hash, so prompt or model changes do not orphan previously generated images.
+7 -9
internal/ai/client.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - "careme/internal/kroger" 15 14 "careme/internal/locations" 16 15 17 16 openai "github.com/openai/openai-go/v3" ··· 34 33 Body io.Reader 35 34 } 36 35 37 - // todo collapse closer to 36 + // how close should this be to Input ingredint. Should we also add aisle or just echo productid so we can look it up 38 37 type Ingredient struct { 39 38 Name string `json:"name"` 40 39 Quantity string `json:"quantity"` // should this and price be numbers? need units then ··· 343 342 ) 344 343 } 345 344 346 - func (c *client) PickWine(ctx context.Context, recipe Recipe, wines []kroger.Ingredient) (*WineSelection, error) { 345 + func (c *client) PickWine(ctx context.Context, recipe Recipe, wines []InputIngredient) (*WineSelection, error) { 347 346 prompt, err := buildWineSelectionPrompt(recipe, wines) 348 347 if err != nil { 349 348 return nil, fmt.Errorf("failed to build wine selection prompt: %w", err) ··· 369 368 return &selection, nil 370 369 } 371 370 372 - // is this dependency on krorger unncessary? just pass in a blob of toml or whatever? same with last recipes? 373 - func (c *client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 371 + func (c *client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 374 372 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes) 375 373 if err != nil { 376 374 return nil, fmt.Errorf("failed to build recipe messages: %w", err) ··· 415 413 } 416 414 417 415 // similiar to image generation builder 418 - func buildWineSelectionPrompt(recipe Recipe, wines []kroger.Ingredient) (string, error) { 416 + func buildWineSelectionPrompt(recipe Recipe, wines []InputIngredient) (string, error) { 419 417 var wineTSV strings.Builder 420 - if err := kroger.ToTSV(wines, &wineTSV); err != nil { 418 + if err := InputIngredientsToTSV(wines, &wineTSV); err != nil { 421 419 return "", fmt.Errorf("failed to convert wines to TSV: %w", err) 422 420 } 423 421 ··· 436 434 } 437 435 438 436 // buildRecipeMessages creates separate messages for the LLM to process more efficiently 439 - func (c *client) buildRecipeMessages(location *locations.Location, saleIngredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 437 + func (c *client) buildRecipeMessages(location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) { 440 438 var messages []responses.ResponseInputItemUnionParam 441 439 // constants we might make variable later 442 440 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+".")) ··· 447 445 448 446 ingredientsMessage := fmt.Sprintf("%d ingredients available in TSV format with header.\n", len(saleIngredients)) 449 447 var buf strings.Builder 450 - if err := kroger.ToTSV(saleIngredients, &buf); err != nil { 448 + if err := InputIngredientsToTSV(saleIngredients, &buf); err != nil { 451 449 return nil, fmt.Errorf("failed to convert ingredients to TSV: %w", err) 452 450 } 453 451 ingredientsMessage += buf.String()
+265
internal/ai/ingredient_grade.go
··· 1 + package ai 2 + 3 + import ( 4 + "context" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "hash/fnv" 9 + "io" 10 + "log/slog" 11 + "strings" 12 + 13 + "github.com/invopop/jsonschema" 14 + openai "github.com/openai/openai-go/v3" 15 + "github.com/openai/openai-go/v3/option" 16 + "github.com/openai/openai-go/v3/responses" 17 + "github.com/samber/lo" 18 + ) 19 + 20 + const ( 21 + defaultIngredientGradeModel = openai.ChatModelGPT5Mini 22 + ) 23 + 24 + const ingredientGradeSystemInstruction = ` 25 + You review grocery catalog items before they are shown to a home recipe generator. 26 + 27 + Score each item from 0 to 10 for usefulness as an ingredient in home-cooked recipes. 28 + 29 + Strongly reward: 30 + - raw, fresh, whole, or minimally processed produce, meat, seafood, dairy, grains, legumes, herbs, and spices 31 + - ingredients that can support many recipe styles or cuisines. Reward diverse ingredients that are hard to make at home. 32 + - less common but real cooking ingredients, including greens, roots, organ meats, bones, and seasonal produce 33 + 34 + Strongly penalize: 35 + - ready-to-eat foods, meal kits, bowls, snack trays, party trays, dips, gravies, mixes, and prepared sides 36 + - items already cooked, heavily seasoned, sauced, breaded, cured, or packaged with dip/sauce 37 + - formats intended mainly for snacking or immediate eating rather than cooking 38 + - pre-cut fruit unless it is still broadly useful for cooking or baking 39 + 40 + 41 + Scoring anchors: 42 + - 9-10: excellent raw/fresh flexible cooking ingredient, e.g. whole vegetables, greens, roots, raw meats, fresh fruit useful in baking/cooking 43 + - 7-8: strong ingredient but with some limitation, e.g. pre-seasoned sausage, niche produce, soup bones, cooked seafood 44 + - 4-6: usable but narrow, processed, pre-cut, pre-mixed, or convenience-oriented 45 + - 0-3: ready-to-eat snack/meal/kit/dip/sauce/condiment with little recipe flexibility 46 + 47 + Important calibration: 48 + - Do not downgrade an ingredient just because it is uncommon. Rutabaga, collard greens, artichokes, yuca, pears, soup bones, and chicken livers are valid cooking ingredients. 49 + - Do downgrade items whose catalog wording implies they are mostly finished foods or snack formats. 50 + 51 + Return JSON only. Preserve each input id/index exactly. Be concise.` 52 + 53 + // this is wire compatible with kroger.Ingredient eventually it should replace it in what staples returns 54 + type InputIngredient struct { 55 + ProductID string `json:"id,omitempty"` 56 + AisleNumber string `json:"number,omitempty"` // this is a dumb json name fix it later 57 + Brand string `json:"brand,omitempty"` 58 + Description string `json:"description,omitempty"` 59 + Size string `json:"size,omitempty"` 60 + PriceRegular *float32 `json:"regularPrice,omitempty"` 61 + PriceSale *float32 `json:"salePrice,omitempty"` 62 + Categories []string `json:"categories,omitempty"` 63 + Grade *IngredientGrade `json:"grade,omitempty"` 64 + } 65 + 66 + type IngredientGrade struct { 67 + Score int `json:"score"` 68 + Reason string `json:"reason"` 69 + } 70 + 71 + // Not a big fand of the number of places that normalize should be done once and not always 72 + func NormalizeInputIngredient(ingredient InputIngredient) InputIngredient { 73 + ingredient.ProductID = strings.TrimSpace(ingredient.ProductID) 74 + ingredient.AisleNumber = strings.TrimSpace(ingredient.AisleNumber) 75 + ingredient.Brand = strings.TrimSpace(ingredient.Brand) 76 + ingredient.Description = strings.TrimSpace(ingredient.Description) 77 + ingredient.Size = strings.TrimSpace(ingredient.Size) 78 + return ingredient 79 + } 80 + 81 + func (i InputIngredient) Hash() string { 82 + fnv := fnv.New128a() 83 + lo.Must(io.WriteString(fnv, strings.ToLower(strings.TrimSpace(i.ProductID)))) 84 + lo.Must(io.WriteString(fnv, strings.ToLower(strings.TrimSpace(i.Brand)))) 85 + lo.Must(io.WriteString(fnv, strings.ToLower(strings.TrimSpace(i.Description)))) 86 + lo.Must(io.WriteString(fnv, strings.ToLower(strings.TrimSpace(i.Size)))) 87 + return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 88 + } 89 + 90 + type ingredientGradeResponseItem struct { 91 + ProductID string `json:"id"` 92 + Score int `json:"score" jsonschema:"minimum=0,maximum=10"` 93 + Reason string `json:"reason"` 94 + } 95 + 96 + type ingredientBatchGradeResponse struct { 97 + Grades []ingredientGradeResponseItem `json:"grades" jsonschema:"required"` 98 + } 99 + 100 + type ingredientGrader struct { 101 + apiKey string 102 + model string 103 + cacheVersion string 104 + schema map[string]any 105 + } 106 + 107 + func ingredientGradeCacheVersion(model, systemInstruction string) string { 108 + fnv := fnv.New128a() 109 + lo.Must(io.WriteString(fnv, model)) 110 + lo.Must(io.WriteString(fnv, systemInstruction)) 111 + return base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 112 + } 113 + 114 + func NewIngredientGrader(apiKey, model string) *ingredientGrader { 115 + model = strings.TrimSpace(model) 116 + if model == "" { 117 + model = defaultIngredientGradeModel 118 + } 119 + return &ingredientGrader{ 120 + apiKey: strings.TrimSpace(apiKey), 121 + model: model, 122 + cacheVersion: ingredientGradeCacheVersion(model, ingredientGradeSystemInstruction), 123 + schema: ingredientGradeJSONSchema(), 124 + } 125 + } 126 + 127 + func (g *ingredientGrader) CacheVersion() string { 128 + return g.cacheVersion 129 + } 130 + 131 + func (g *ingredientGrader) GradeIngredients(ctx context.Context, ingredients []InputIngredient) ([]InputIngredient, error) { 132 + if len(ingredients) == 0 { 133 + return nil, nil 134 + } 135 + 136 + items := make([]InputIngredient, len(ingredients)) 137 + for i, ingredient := range ingredients { 138 + item := NormalizeInputIngredient(ingredient) 139 + if item.Grade != nil { 140 + return nil, fmt.Errorf("already graded ingredient %s", item.ProductID) 141 + } 142 + items[i] = item 143 + } 144 + 145 + prompt, err := buildIngredientGradePrompt(items) 146 + if err != nil { 147 + return nil, fmt.Errorf("failed to build ingredient grading prompt: %w", err) 148 + } 149 + 150 + client := openai.NewClient(option.WithAPIKey(g.apiKey)) 151 + resp, err := client.Responses.New(ctx, responses.ResponseNewParams{ 152 + Model: g.model, 153 + Instructions: openai.String(ingredientGradeSystemInstruction), 154 + Input: responses.ResponseNewParamsInputUnion{ 155 + OfInputItemList: []responses.ResponseInputItemUnionParam{user(prompt)}, 156 + }, 157 + Text: scheme(g.schema), 158 + }) 159 + if err != nil { 160 + return nil, fmt.Errorf("failed to grade ingredients: %w", err) 161 + } 162 + slog.InfoContext(ctx, "Ingredient grading usage", "model", g.model, responseUsageLogAttr(resp.Usage)) 163 + 164 + return parseIngredientGrades(resp.OutputText(), items) 165 + } 166 + 167 + func buildIngredientGradePrompt(items []InputIngredient) (string, error) { 168 + type ingredientGradePromptItem struct { 169 + ProductID string `json:"id"` 170 + Brand string `json:"brand,omitempty"` 171 + Description string `json:"description,omitempty"` 172 + Size string `json:"size,omitempty"` 173 + } 174 + promptItems := make([]ingredientGradePromptItem, len(items)) 175 + for i, item := range items { 176 + promptItems[i] = ingredientGradePromptItem{ 177 + ProductID: item.ProductID, 178 + Brand: item.Brand, 179 + Description: item.Description, 180 + Size: item.Size, 181 + } 182 + } 183 + 184 + // TSV here instead? 185 + body, err := json.MarshalIndent(promptItems, "", " ") 186 + if err != nil { 187 + return "", fmt.Errorf("marshal ingredient batch: %w", err) 188 + } 189 + return fmt.Sprintf("Grade these grocery catalog items for home recipe generation.\nReturn one result per item, preserving each id exactly.\nReturn JSON only matching the provided schema.\nIngredient JSON:\n%s", string(body)), nil 190 + } 191 + 192 + func parseIngredientGrades(body string, items []InputIngredient) ([]InputIngredient, error) { 193 + body = strings.TrimSpace(body) 194 + if body == "" { 195 + return nil, fmt.Errorf("empty ingredient grading response from model") 196 + } 197 + 198 + itemMap := make(map[string]InputIngredient, len(items)) 199 + for _, item := range items { 200 + productID := strings.TrimSpace(item.ProductID) 201 + if productID == "" { 202 + return nil, fmt.Errorf("ingredient product_id is required") 203 + } 204 + if _, ok := itemMap[productID]; ok { 205 + return nil, fmt.Errorf("ingredient grading duplicated input product_id %q", productID) 206 + } 207 + itemMap[productID] = item 208 + } 209 + 210 + var parsed ingredientBatchGradeResponse 211 + if err := json.Unmarshal([]byte(body), &parsed); err != nil { 212 + return nil, fmt.Errorf("failed to parse ingredient grading response: %w", err) 213 + } 214 + if len(parsed.Grades) != len(items) { 215 + return nil, fmt.Errorf("ingredient grading response count mismatch: got %d want %d", len(parsed.Grades), len(items)) 216 + } 217 + 218 + var graded []InputIngredient 219 + seen := make(map[string]bool, len(items)) 220 + for _, result := range parsed.Grades { 221 + productID := strings.TrimSpace(result.ProductID) 222 + if productID == "" { 223 + return nil, fmt.Errorf("ingredient grade missing product_id") 224 + } 225 + item, ok := itemMap[productID] 226 + if !ok { 227 + return nil, fmt.Errorf("ingredient grade returned unknown product_id %q", productID) 228 + } 229 + if seen[productID] { 230 + return nil, fmt.Errorf("ingredient grading duplicated product_id %q", productID) 231 + } 232 + seen[productID] = true 233 + if result.Score < 0 || result.Score > 10 { 234 + return nil, fmt.Errorf("ingredient score must be between 0 and 10") 235 + } 236 + if strings.TrimSpace(result.Reason) == "" { 237 + return nil, fmt.Errorf("ingredient grading reason is required") 238 + } 239 + 240 + item.Grade = &IngredientGrade{ 241 + Score: result.Score, 242 + Reason: strings.TrimSpace(result.Reason), 243 + } 244 + graded = append(graded, item) 245 + } 246 + 247 + return graded, nil 248 + } 249 + 250 + func ingredientGradeJSONSchema() map[string]any { 251 + r := jsonschema.Reflector{ 252 + DoNotReference: true, 253 + ExpandedStruct: true, 254 + } 255 + schema := r.Reflect(&ingredientBatchGradeResponse{}) 256 + body, err := json.Marshal(schema) 257 + if err != nil { 258 + panic(fmt.Sprintf("marshal ingredient grade schema: %v", err)) 259 + } 260 + var out map[string]any 261 + if err := json.Unmarshal(body, &out); err != nil { 262 + panic(fmt.Sprintf("decode ingredient grade schema: %v", err)) 263 + } 264 + return out 265 + }
+151
internal/ai/ingredient_grade_test.go
··· 1 + package ai 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + "github.com/stretchr/testify/require" 8 + ) 9 + 10 + func TestNormalizeInputIngredientNormalizesFieldsAndSetsID(t *testing.T) { 11 + ingredient := NormalizeInputIngredient(InputIngredient{ 12 + ProductID: " 123 ", 13 + AisleNumber: " A5 ", 14 + Brand: " Farm Stand ", 15 + Description: " Baby Spinach ", 16 + Size: " 5 oz ", 17 + PriceRegular: float32Ptr(4.99), 18 + PriceSale: float32Ptr(3.49), 19 + Categories: []string{" greens ", "Produce", "produce", ""}, 20 + }) 21 + 22 + assert.Equal(t, "123", ingredient.ProductID) 23 + assert.Equal(t, "A5", ingredient.AisleNumber) 24 + assert.Equal(t, "Farm Stand", ingredient.Brand) 25 + assert.Equal(t, "Baby Spinach", ingredient.Description) 26 + assert.Equal(t, "5 oz", ingredient.Size) 27 + require.NotNil(t, ingredient.PriceRegular) 28 + require.NotNil(t, ingredient.PriceSale) 29 + assert.Equal(t, float32(4.99), *ingredient.PriceRegular) 30 + assert.Equal(t, float32(3.49), *ingredient.PriceSale) 31 + } 32 + 33 + func float32Ptr(v float32) *float32 { 34 + return &v 35 + } 36 + 37 + func TestInputIngredientHashStableAcrossCategoryOrder(t *testing.T) { 38 + left := NormalizeInputIngredient(InputIngredient{ 39 + ProductID: "123", 40 + Description: "Baby Spinach", 41 + Categories: []string{"Produce", "Greens"}, 42 + }) 43 + right := NormalizeInputIngredient(InputIngredient{ 44 + ProductID: "123", 45 + Description: "Baby Spinach", 46 + Categories: []string{"Greens", "Produce"}, 47 + }) 48 + 49 + assert.Equal(t, left.Hash(), right.Hash()) 50 + } 51 + 52 + func TestIngredientGradeCacheVersionChangesWhenPromptOrModelChanges(t *testing.T) { 53 + base := (&ingredientGrader{cacheVersion: ingredientGradeCacheVersion("gpt-5-mini", "prompt a")}).CacheVersion() 54 + same := (&ingredientGrader{cacheVersion: ingredientGradeCacheVersion("gpt-5-mini", "prompt a")}).CacheVersion() 55 + differentModel := (&ingredientGrader{cacheVersion: ingredientGradeCacheVersion("gpt-5-nano", "prompt a")}).CacheVersion() 56 + differentPrompt := (&ingredientGrader{cacheVersion: ingredientGradeCacheVersion("gpt-5-mini", "prompt b")}).CacheVersion() 57 + 58 + assert.Equal(t, base, same) 59 + assert.NotEqual(t, base, differentModel) 60 + assert.NotEqual(t, base, differentPrompt) 61 + } 62 + 63 + func TestBuildIngredientGradePrompt(t *testing.T) { 64 + ingredient := NormalizeInputIngredient(InputIngredient{ 65 + Description: "Asparagus", 66 + ProductID: "foobar", 67 + Categories: []string{"Produce"}, 68 + }) 69 + prompt, err := buildIngredientGradePrompt([]InputIngredient{ingredient}) 70 + require.NoError(t, err) 71 + assert.Contains(t, prompt, "preserving each id") 72 + assert.Contains(t, prompt, `"id": "foobar"`) 73 + assert.Contains(t, prompt, "Return JSON only matching the provided schema.") 74 + assert.Contains(t, prompt, `"description": "Asparagus"`) 75 + } 76 + 77 + func TestParseIngredientGrades(t *testing.T) { 78 + items := []InputIngredient{NormalizeInputIngredient(InputIngredient{ 79 + Description: "Asparagus", 80 + ProductID: "ingredient-1", 81 + })} 82 + graded, err := parseIngredientGrades(`{"grades":[{"id":"`+items[0].ProductID+`","score":8,"reason":"Fresh produce with broad weeknight use."}]}`, items) 83 + require.NoError(t, err) 84 + require.Len(t, graded, 1) 85 + require.NotNil(t, graded[0].Grade) 86 + assert.Equal(t, 8, graded[0].Grade.Score) 87 + assert.Equal(t, "Fresh produce with broad weeknight use.", graded[0].Grade.Reason) 88 + assert.Equal(t, "Asparagus", graded[0].Description) 89 + } 90 + 91 + func TestParseIngredientGradesRejectsInvalidResponses(t *testing.T) { 92 + items := []InputIngredient{NormalizeInputIngredient(InputIngredient{ProductID: "ingredient-1"})} 93 + _, err := parseIngredientGrades(`{"grades":[{"id":"`+items[0].ProductID+`","score":11,"reason":"too high"}]}`, items) 94 + require.Error(t, err) 95 + assert.Contains(t, err.Error(), "between 0 and 10") 96 + 97 + _, err = parseIngredientGrades(`{"grades":[{"id":"`+items[0].ProductID+`","score":3,"reason":" "}]}`, items) 98 + require.Error(t, err) 99 + assert.Contains(t, err.Error(), "reason is required") 100 + 101 + _, err = parseIngredientGrades(`{"grades":[]}`, items) 102 + require.Error(t, err) 103 + assert.Contains(t, err.Error(), "count mismatch") 104 + } 105 + 106 + func TestParseIngredientGradesMatchesByIDInsteadOfOrder(t *testing.T) { 107 + items := []InputIngredient{ 108 + NormalizeInputIngredient(InputIngredient{Description: "Potato Chips", ProductID: "b"}), 109 + NormalizeInputIngredient(InputIngredient{Description: "Asparagus", ProductID: "a"}), 110 + } 111 + 112 + body := `{"grades":[{"id":"` + items[1].ProductID + `","score":9,"reason":"Fresh vegetable."},{"id":"` + items[0].ProductID + `","score":2,"reason":"Snack food."}]}` 113 + graded, err := parseIngredientGrades(body, items) 114 + require.NoError(t, err) 115 + require.Len(t, graded, 2) 116 + 117 + byID := make(map[string]InputIngredient, len(graded)) 118 + for _, ingredient := range graded { 119 + require.NotNil(t, ingredient.Grade) 120 + byID[ingredient.ProductID] = ingredient 121 + } 122 + 123 + require.Contains(t, byID, "b") 124 + assert.Equal(t, "Potato Chips", byID["b"].Description) 125 + assert.Equal(t, 2, byID["b"].Grade.Score) 126 + 127 + require.Contains(t, byID, "a") 128 + assert.Equal(t, "Asparagus", byID["a"].Description) 129 + assert.Equal(t, 9, byID["a"].Grade.Score) 130 + } 131 + 132 + func TestParseIngredientGradesRejectsDuplicateInputProductIDs(t *testing.T) { 133 + items := []InputIngredient{ 134 + NormalizeInputIngredient(InputIngredient{Description: "Asparagus", ProductID: "ingredient-1"}), 135 + NormalizeInputIngredient(InputIngredient{Description: "Broccoli", ProductID: "ingredient-1"}), 136 + } 137 + 138 + _, err := parseIngredientGrades(`{"grades":[{"id":"ingredient-1","score":8,"reason":"Fresh vegetable."},{"id":"ingredient-1","score":7,"reason":"Another vegetable."}]}`, items) 139 + require.Error(t, err) 140 + assert.Contains(t, err.Error(), "duplicated input product_id") 141 + } 142 + 143 + func TestIngredientGradeSchemaOmitsOperationalFields(t *testing.T) { 144 + schema := ingredientGradeJSONSchema() 145 + properties, ok := schema["properties"].(map[string]any) 146 + require.True(t, ok) 147 + 148 + _, hasSchemaVersion := properties["schema_version"] 149 + 150 + assert.False(t, hasSchemaVersion) 151 + }
+46
internal/ai/input_ingredient_tsv.go
··· 1 + package ai 2 + 3 + import ( 4 + "encoding/csv" 5 + "fmt" 6 + "io" 7 + ) 8 + 9 + func InputIngredientsToTSV(ingredients []InputIngredient, w io.Writer) error { 10 + csvw := csv.NewWriter(w) 11 + csvw.Comma = '\t' 12 + header := []string{"ProductId", "AisleNumber", "Brand", "Description", "Size", "PriceRegular", "PriceSale"} 13 + if err := csvw.Write(header); err != nil { 14 + return err 15 + } 16 + for _, ingredient := range ingredients { 17 + priceSale := ingredient.PriceSale 18 + if priceSale == nil { 19 + priceSale = ingredient.PriceRegular 20 + } 21 + row := []string{ 22 + ingredient.ProductID, 23 + ingredient.AisleNumber, 24 + ingredient.Brand, 25 + ingredient.Description, 26 + ingredient.Size, 27 + priceToString(ingredient.PriceRegular), 28 + priceToString(priceSale), 29 + } 30 + if len(header) != len(row) { 31 + return fmt.Errorf("header and row length mismatch: %d vs %d", len(header), len(row)) 32 + } 33 + if err := csvw.Write(row); err != nil { 34 + return err 35 + } 36 + } 37 + csvw.Flush() 38 + return csvw.Error() 39 + } 40 + 41 + func priceToString(price *float32) string { 42 + if price == nil { 43 + return "" 44 + } 45 + return fmt.Sprintf("%.2f", *price) 46 + }
+29
internal/ai/input_ingredient_tsv_test.go
··· 1 + package ai 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestInputIngredientsToTSV_UsesRegularPriceWhenSaleMissing(t *testing.T) { 9 + var buf strings.Builder 10 + err := InputIngredientsToTSV([]InputIngredient{{ 11 + ProductID: "item-1", 12 + AisleNumber: "12", 13 + Brand: "Acme", 14 + Description: "Asparagus", 15 + Size: "1 lb", 16 + PriceRegular: float32Ptr(4.99), 17 + }}, &buf) 18 + if err != nil { 19 + t.Fatalf("inputIngredientsToTSV returned error: %v", err) 20 + } 21 + 22 + got := buf.String() 23 + if !strings.Contains(got, "ProductId\tAisleNumber\tBrand\tDescription\tSize\tPriceRegular\tPriceSale") { 24 + t.Fatalf("expected TSV header, got %q", got) 25 + } 26 + if !strings.Contains(got, "item-1\t12\tAcme\tAsparagus\t1 lb\t4.99\t4.99") { 27 + t.Fatalf("expected regular price copied into sale column, got %q", got) 28 + } 29 + }
+3 -13
internal/ai/recipe_test.go
··· 7 7 "strings" 8 8 "testing" 9 9 10 - "careme/internal/kroger" 11 - 12 10 openai "github.com/openai/openai-go/v3" 13 11 "github.com/openai/openai-go/v3/responses" 14 12 ) ··· 136 134 DrinkPairing: "Pinot Noir", 137 135 WineStyles: []string{"Pinot Noir", "Chardonnay"}, 138 136 } 139 - wines := []kroger.Ingredient{ 140 - {Description: strPtr("Pinot Noir"), Size: strPtr("750mL"), PriceRegular: float32Ptr(13.99)}, 137 + wines := []InputIngredient{ 138 + {ProductID: "pinot-noir-1", Description: "Pinot Noir", Size: "750mL", PriceRegular: float32Ptr(13.99)}, 141 139 } 142 140 143 141 prompt, err := buildWineSelectionPrompt(recipe, wines) ··· 154 152 if !strings.Contains(prompt, "- Roast until golden.\n- Finish with lemon juice.\n") { 155 153 t.Fatalf("expected instructions replay in prompt: %s", prompt) 156 154 } 157 - if !strings.Contains(prompt, "Candidate wines TSV:\nProductId\tAisleNumber\tBrand\tDescription\tSize\tPriceRegular\tPriceSale\n\t\t\tPinot Noir\t750mL\t13.99\t13.99\n") { 155 + if !strings.Contains(prompt, "Candidate wines TSV:\nProductId\tAisleNumber\tBrand\tDescription\tSize\tPriceRegular\tPriceSale\npinot-noir-1\t\t\tPinot Noir\t750mL\t13.99\t13.99\n") { 158 156 t.Fatalf("expected candidate wines TSV in prompt: %s", prompt) 159 157 } 160 158 } ··· 226 224 t.Fatalf("unexpected attrs: %#v", attr.Value.Group()) 227 225 } 228 226 } 229 - 230 - func strPtr(s string) *string { 231 - return &s 232 - } 233 - 234 - func float32Ptr(v float32) *float32 { 235 - return &v 236 - }
+25 -15
internal/config/config.go
··· 17 17 ) 18 18 19 19 type Config struct { 20 - AI AIConfig `json:"ai"` 21 - Gemini GeminiConfig `json:"gemini"` 22 - Kroger KrogerConfig `json:"kroger"` 23 - Walmart WalmartConfig `json:"walmart"` 24 - Aldi AldiConfig `json:"aldi"` 25 - WholeFoods WholeFoodsConfig `json:"wholefoods"` 26 - Albertsons AlbertsonsConfig `json:"albertsons"` 27 - Publix PublixConfig `json:"publix"` 28 - HEB HEBConfig `json:"heb"` 29 - Wegmans WegmansConfig `json:"wegmans"` 30 - BrightDataProxy brightdata.ProxyConfig `json:"brightdata_proxy"` 31 - Mocks MockConfig `json:"mocks"` 32 - Clerk ClerkConfig `json:"clerk"` 33 - Admin AdminConfig `json:"admin"` 34 - PublicOrigin string `json:"public_origin"` 20 + AI AIConfig `json:"ai"` 21 + Gemini GeminiConfig `json:"gemini"` 22 + IngredientGrading IngredientGradingConfig `json:"ingredient_grading"` 23 + Kroger KrogerConfig `json:"kroger"` 24 + Walmart WalmartConfig `json:"walmart"` 25 + Aldi AldiConfig `json:"aldi"` 26 + WholeFoods WholeFoodsConfig `json:"wholefoods"` 27 + Albertsons AlbertsonsConfig `json:"albertsons"` 28 + Publix PublixConfig `json:"publix"` 29 + HEB HEBConfig `json:"heb"` 30 + Wegmans WegmansConfig `json:"wegmans"` 31 + BrightDataProxy brightdata.ProxyConfig `json:"brightdata_proxy"` 32 + Mocks MockConfig `json:"mocks"` 33 + Clerk ClerkConfig `json:"clerk"` 34 + Admin AdminConfig `json:"admin"` 35 + PublicOrigin string `json:"public_origin"` 35 36 } 36 37 37 38 type AIConfig struct { 38 39 APIKey string `json:"api_key"` 40 + } 41 + 42 + type IngredientGradingConfig struct { 43 + Enable bool `json:"enable"` 44 + Model string `json:"model"` 39 45 } 40 46 41 47 type GeminiConfig struct { ··· 163 169 config := &Config{ 164 170 AI: AIConfig{ 165 171 APIKey: os.Getenv("AI_API_KEY"), 172 + }, 173 + IngredientGrading: IngredientGradingConfig{ 174 + Enable: envEnabled("INGREDIENT_GRADING_ENABLE"), 175 + Model: os.Getenv("INGREDIENT_GRADING_MODEL"), 166 176 }, 167 177 Gemini: GeminiConfig{ 168 178 APIKey: os.Getenv("GEMINI_API_KEY"),
+104
internal/ingredients/grading/cache.go
··· 1 + package grading 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "log/slog" 8 + 9 + "careme/internal/ai" 10 + "careme/internal/cache" 11 + "careme/internal/parallelism" 12 + ) 13 + 14 + var _ grader = &cachingGrader{} 15 + 16 + type baseGrader interface { 17 + GradeIngredients(ctx context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) 18 + CacheVersion() string 19 + } 20 + 21 + type cachingGrader struct { 22 + cacheVersion string 23 + grader baseGrader 24 + store store 25 + } 26 + 27 + func newCachingGrader(grader baseGrader, store store) *cachingGrader { 28 + if grader == nil { 29 + panic("ingredient grader must not be nil") 30 + } 31 + cacheVersion := grader.CacheVersion() 32 + if cacheVersion == "" { 33 + panic("ingredient grade cache version must not be empty") 34 + } 35 + return &cachingGrader{ 36 + cacheVersion: cacheVersion, 37 + grader: grader, 38 + store: store, 39 + } 40 + } 41 + 42 + func (c *cachingGrader) GradeIngredients(ctx context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 43 + type lookupResult struct { 44 + cached *ai.InputIngredient 45 + missing *ai.InputIngredient 46 + } 47 + 48 + lookups, err := parallelism.MapWithErrors(ingredients, func(ingredient ai.InputIngredient) (lookupResult, error) { 49 + if ingredient.Grade != nil { 50 + return lookupResult{cached: &ingredient}, nil 51 + } 52 + 53 + key := cacheKey(c.cacheVersion + "/" + ingredientHash(ingredient)) 54 + gradedIngredient, err := c.store.Load(ctx, key) 55 + if err == nil { 56 + return lookupResult{cached: gradedIngredient}, nil 57 + } 58 + if !errors.Is(err, cache.ErrNotFound) { 59 + slog.ErrorContext(ctx, "failed to load cached ingredient grade", "key", key, "error", err) 60 + return lookupResult{}, fmt.Errorf("load cached ingredient grade for %q: %w", ingredientLabel(ingredient), err) 61 + } 62 + return lookupResult{missing: &ingredient}, nil 63 + }) 64 + if err != nil { 65 + return nil, err 66 + } 67 + 68 + results := make([]ai.InputIngredient, 0, len(ingredients)) 69 + missingIngredients := make([]ai.InputIngredient, 0, len(ingredients)) 70 + for _, lookup := range lookups { 71 + if lookup.cached != nil { 72 + results = append(results, *lookup.cached) 73 + continue 74 + } 75 + if lookup.missing != nil { 76 + missingIngredients = append(missingIngredients, *lookup.missing) 77 + } 78 + } 79 + 80 + if len(missingIngredients) == 0 { 81 + return results, nil 82 + } 83 + 84 + gradedIngredients, err := c.grader.GradeIngredients(ctx, missingIngredients) 85 + if err != nil { 86 + return nil, err 87 + } 88 + if len(gradedIngredients) != len(missingIngredients) { 89 + return nil, fmt.Errorf("ingredient grader returned %d ingredients for %d inputs", len(gradedIngredients), len(missingIngredients)) 90 + } 91 + 92 + for _, gradedIngredient := range gradedIngredients { 93 + if gradedIngredient.Grade == nil { 94 + return nil, fmt.Errorf("ingredient grader returned nil grade for %q", ingredientLabel(gradedIngredient)) 95 + } 96 + key := cacheKey(c.cacheVersion + "/" + ingredientHash(gradedIngredient)) 97 + if err := c.store.Save(ctx, key, &gradedIngredient); err != nil { 98 + slog.ErrorContext(ctx, "failed to cache ingredient grade", "key", key, "ingredient", ingredientLabel(gradedIngredient), "error", err) 99 + } 100 + results = append(results, gradedIngredient) 101 + } 102 + 103 + return results, nil 104 + }
+81
internal/ingredients/grading/manager.go
··· 1 + package grading 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + 7 + "careme/internal/ai" 8 + "careme/internal/cache" 9 + "careme/internal/config" 10 + "careme/internal/parallelism" 11 + 12 + "github.com/samber/lo" 13 + ) 14 + 15 + const ( 16 + ingredientGradeBatchSize = 30 17 + ) 18 + 19 + type grader interface { 20 + GradeIngredients(ctx context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) 21 + } 22 + 23 + type rubberstamp struct{} 24 + 25 + func (r rubberstamp) GradeIngredients(_ context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 26 + results := make([]ai.InputIngredient, 0, len(ingredients)) 27 + for _, ingredient := range ingredients { 28 + ingredient.Grade = &ai.IngredientGrade{ 29 + Score: 10, 30 + Reason: "ingredient grading disabled", 31 + } 32 + results = append(results, ingredient) 33 + } 34 + return results, nil 35 + } 36 + 37 + type multiGrader struct { 38 + grader grader 39 + } 40 + 41 + func NewManager(cfg *config.Config, c cache.ListCache) grader { 42 + if cfg == nil || !cfg.IngredientGrading.Enable || strings.TrimSpace(cfg.AI.APIKey) == "" { 43 + return rubberstamp{} 44 + } 45 + base := ai.NewIngredientGrader(cfg.AI.APIKey, cfg.IngredientGrading.Model) 46 + return &multiGrader{ 47 + grader: newCachingGrader(base, NewStore(c)), 48 + } 49 + } 50 + 51 + func (m *multiGrader) GradeIngredients(ctx context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 52 + if len(ingredients) == 0 { 53 + return nil, nil 54 + } 55 + 56 + // we assume dedupe before thing come in here 57 + 58 + batches := lo.Chunk(ingredients, ingredientGradeBatchSize) 59 + graded, err := parallelism.Flatten(batches, func(batch []ai.InputIngredient) ([]ai.InputIngredient, error) { 60 + return m.grader.GradeIngredients(ctx, batch) 61 + }) 62 + if err != nil { 63 + // will have cached these 64 + return nil, err 65 + } 66 + return graded, nil 67 + } 68 + 69 + func ingredientHash(ingredient ai.InputIngredient) string { 70 + return ai.NormalizeInputIngredient(ingredient).Hash() 71 + } 72 + 73 + func ingredientLabel(ingredient ai.InputIngredient) string { 74 + if value := strings.TrimSpace(ingredient.Description); value != "" { 75 + return value 76 + } 77 + if value := strings.TrimSpace(ingredient.Brand); value != "" { 78 + return value 79 + } 80 + return strings.TrimSpace(ingredient.ProductID) 81 + }
+125
internal/ingredients/grading/manager_test.go
··· 1 + package grading 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "slices" 7 + "testing" 8 + 9 + "careme/internal/ai" 10 + "careme/internal/cache" 11 + 12 + "github.com/stretchr/testify/assert" 13 + "github.com/stretchr/testify/require" 14 + ) 15 + 16 + const testIngredientGradeCacheVersion = "test-cache-version" 17 + 18 + type stubGradeBackend struct { 19 + grades map[string]ai.InputIngredient 20 + err error 21 + calls [][]ai.InputIngredient 22 + } 23 + 24 + func (s *stubGradeBackend) CacheVersion() string { 25 + return testIngredientGradeCacheVersion 26 + } 27 + 28 + func (s *stubGradeBackend) GradeIngredients(_ context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 29 + if s.err != nil { 30 + return nil, s.err 31 + } 32 + s.calls = append(s.calls, append([]ai.InputIngredient(nil), ingredients...)) 33 + out := make([]ai.InputIngredient, len(ingredients)) 34 + for i, ingredient := range ingredients { 35 + key := ai.NormalizeInputIngredient(ingredient).Hash() 36 + if gradedIngredient, ok := s.grades[key]; ok { 37 + out[i] = gradedIngredient 38 + continue 39 + } 40 + out[i] = ai.InputIngredient{ 41 + ProductID: ingredient.ProductID, 42 + Brand: ingredient.Brand, 43 + Description: ingredient.Description, 44 + Size: ingredient.Size, 45 + Categories: slices.Clone(ingredient.Categories), 46 + Grade: &ai.IngredientGrade{ 47 + Score: 10, 48 + Reason: "default", 49 + }, 50 + } 51 + } 52 + return out, nil 53 + } 54 + 55 + func TestCachingGraderBatchesMissingIngredientsInChunksOf30(t *testing.T) { 56 + cacheStore := NewStore(cache.NewInMemoryCache()) 57 + backend := &stubGradeBackend{} 58 + grader := newCachingGrader(backend, cacheStore) 59 + 60 + inputs := make([]ai.InputIngredient, 65) 61 + for i := range inputs { 62 + inputs[i] = ai.InputIngredient{ 63 + ProductID: fmt.Sprintf("ingredient-%02d", i), 64 + Description: fmt.Sprintf("Ingredient %02d", i), 65 + } 66 + } 67 + 68 + results, err := grader.GradeIngredients(t.Context(), inputs) 69 + require.NoError(t, err) 70 + require.Len(t, results, 65) 71 + require.Len(t, backend.calls, 1) 72 + assert.Len(t, backend.calls[0], 65) 73 + } 74 + 75 + func TestCachingGraderSkipsIngredientsThatAlreadyHaveGrades(t *testing.T) { 76 + cacheStore := NewStore(cache.NewInMemoryCache()) 77 + backend := &stubGradeBackend{} 78 + grader := newCachingGrader(backend, cacheStore) 79 + 80 + preGraded := ai.InputIngredient{ 81 + ProductID: "ingredient-00", 82 + Description: "Ingredient 00", 83 + Grade: &ai.IngredientGrade{ 84 + Score: 9, 85 + Reason: "already graded", 86 + }, 87 + } 88 + ungraded := ai.InputIngredient{ 89 + ProductID: "ingredient-01", 90 + Description: "Ingredient 01", 91 + } 92 + 93 + results, err := grader.GradeIngredients(t.Context(), []ai.InputIngredient{preGraded, ungraded}) 94 + require.NoError(t, err) 95 + require.Len(t, results, 2) 96 + require.Len(t, backend.calls, 1) 97 + assert.Equal(t, []ai.InputIngredient{ungraded}, backend.calls[0]) 98 + require.NotNil(t, results[0].Grade) 99 + assert.Equal(t, 9, results[0].Grade.Score) 100 + require.NotNil(t, results[1].Grade) 101 + } 102 + 103 + func TestMultiGraderBatchesUniqueIngredientsInChunksOf30(t *testing.T) { 104 + cacheStore := NewStore(cache.NewInMemoryCache()) 105 + backend := &stubGradeBackend{} 106 + manager := &multiGrader{ 107 + grader: newCachingGrader(backend, cacheStore), 108 + } 109 + 110 + ingredients := make([]ai.InputIngredient, 65) 111 + for i := range ingredients { 112 + ingredients[i] = ai.InputIngredient{ 113 + ProductID: fmt.Sprintf("ingredient-%02d", i), 114 + Description: fmt.Sprintf("Ingredient %02d", i), 115 + } 116 + } 117 + 118 + results, err := manager.GradeIngredients(t.Context(), ingredients) 119 + require.NoError(t, err) 120 + require.Len(t, results, 65) 121 + require.Len(t, backend.calls, 3) 122 + callSizes := []int{len(backend.calls[0]), len(backend.calls[1]), len(backend.calls[2])} 123 + slices.Sort(callSizes) 124 + assert.Equal(t, []int{5, 30, 30}, callSizes) 125 + }
+54
internal/ingredients/grading/store.go
··· 1 + package grading 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + ) 11 + 12 + const cachePrefix = "ingredient_grades/" 13 + 14 + func cacheKey(ingredientHash string) string { 15 + return cachePrefix + ingredientHash 16 + } 17 + 18 + type store struct { 19 + cache cache.ListCache 20 + } 21 + 22 + func NewStore(c cache.ListCache) store { 23 + if c == nil { 24 + panic("cache must not be nil") 25 + } 26 + return store{cache: c} 27 + } 28 + 29 + func (s store) Load(ctx context.Context, key string) (*ai.InputIngredient, error) { 30 + reader, err := s.cache.Get(ctx, key) 31 + if err != nil { 32 + return nil, err 33 + } 34 + defer func() { 35 + _ = reader.Close() 36 + }() 37 + 38 + var ingredient ai.InputIngredient 39 + if err := json.NewDecoder(reader).Decode(&ingredient); err != nil { 40 + return nil, err 41 + } 42 + return &ingredient, nil 43 + } 44 + 45 + func (s store) Save(ctx context.Context, key string, ingredient *ai.InputIngredient) error { 46 + if ingredient == nil { 47 + return fmt.Errorf("graded ingredient is required") 48 + } 49 + body, err := json.Marshal(ingredient) 50 + if err != nil { 51 + return err 52 + } 53 + return s.cache.Put(ctx, key, string(body), cache.Unconditional()) 54 + }
+38
internal/ingredients/grading/store_test.go
··· 1 + package grading 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/cache" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + func TestStoreSaveLoadUsesPrefixedKey(t *testing.T) { 16 + tmpDir := t.TempDir() 17 + store := NewStore(cache.NewFileCache(tmpDir)) 18 + key := cacheKey("ingredient-hash") 19 + ingredient := &ai.InputIngredient{ 20 + ProductID: "ingredient-123", 21 + Description: "Asparagus", 22 + Grade: &ai.IngredientGrade{ 23 + Score: 8, 24 + Reason: "Fresh produce with broad recipe use.", 25 + }, 26 + } 27 + 28 + require.NoError(t, store.Save(t.Context(), key, ingredient)) 29 + _, err := os.Stat(filepath.Join(tmpDir, cachePrefix, "ingredient-hash")) 30 + require.NoError(t, err) 31 + 32 + got, err := store.Load(t.Context(), key) 33 + require.NoError(t, err) 34 + require.NotNil(t, got.Grade) 35 + assert.Equal(t, ingredient.Grade.Score, got.Grade.Score) 36 + assert.Equal(t, ingredient.Grade.Reason, got.Grade.Reason) 37 + assert.Equal(t, ingredient.Description, got.Description) 38 + }
+59 -24
internal/ingredients/server.go
··· 1 1 package ingredients 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "errors" 6 7 "log/slog" 7 8 "net/http" 9 + "slices" 10 + "strings" 8 11 12 + "careme/internal/ai" 9 13 "careme/internal/cache" 10 - "careme/internal/kroger" 11 14 "careme/internal/recipes" 12 15 "careme/internal/routing" 13 16 ) ··· 25 28 } 26 29 27 30 func (s *server) handleIngredients(w http.ResponseWriter, r *http.Request) { 28 - ctx := r.Context() 29 - hash := r.PathValue("hash") 30 - rio := recipes.IO(s.cache) 31 - 32 - params, err := rio.ParamsFromCache(ctx, hash) 31 + ingredients, err := s.loadCachedIngredients(r) 33 32 if err != nil { 34 - if errors.Is(err, cache.ErrNotFound) { 35 - http.Error(w, "parameters not found in cache", http.StatusNotFound) 36 - return 37 - } 38 - slog.ErrorContext(ctx, "failed to load params for hash", "hash", hash, "error", err) 39 - http.Error(w, "failed to fetch params", http.StatusInternalServerError) 33 + s.writeIngredientLoadError(w, r, err) 40 34 return 41 35 } 42 36 43 - locationHash := params.LocationHash() 44 - ingredients, err := rio.IngredientsFromCache(ctx, locationHash) 45 - if err != nil { 46 - if errors.Is(err, cache.ErrNotFound) { 47 - http.Error(w, "ingredients not found in cache", http.StatusNotFound) 48 - return 37 + slices.SortFunc(ingredients, func(a, b ai.InputIngredient) int { 38 + if a.Grade == nil || b.Grade == nil { 39 + return strings.Compare(strings.ToLower(a.Description), strings.ToLower(b.Description)) 49 40 } 50 - slog.ErrorContext(ctx, "failed to load ingredients for hash", "hash", locationHash, "error", err) 51 - http.Error(w, "failed to fetch ingredients", http.StatusInternalServerError) 52 - return 53 - } 41 + return b.Grade.Score - a.Grade.Score 42 + }) 54 43 55 - slog.Info("serving cached ingredients", "location", params.String(), "hash", locationHash) 44 + slog.Info("serving cached ingredients", "path", r.URL.Path) 56 45 if r.URL.Query().Get("format") == "tsv" { 57 46 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 58 - if err := kroger.ToTSV(ingredients, w); err != nil { 47 + if err := ai.InputIngredientsToTSV(ingredients, w); err != nil { 59 48 http.Error(w, "failed to encode ingredients", http.StatusInternalServerError) 60 49 } 61 50 return ··· 69 58 return 70 59 } 71 60 } 61 + 62 + func (s *server) loadCachedIngredients(r *http.Request) ([]ai.InputIngredient, error) { 63 + ctx := r.Context() 64 + hash := r.PathValue("hash") 65 + locationHash, err := s.loadLocationHash(ctx, hash) 66 + if err != nil { 67 + return nil, err 68 + } 69 + 70 + rio := recipes.IO(s.cache) 71 + ingredients, err := rio.IngredientsFromCache(ctx, locationHash) 72 + if err != nil { 73 + if errors.Is(err, cache.ErrNotFound) { 74 + return nil, cache.ErrNotFound 75 + } 76 + slog.ErrorContext(ctx, "failed to load ingredients for hash", "hash", locationHash, "error", err) 77 + return nil, err 78 + } 79 + return ingredients, nil 80 + } 81 + 82 + func (s *server) loadLocationHash(ctx context.Context, hash string) (string, error) { 83 + rio := recipes.IO(s.cache) 84 + params, err := rio.ParamsFromCache(ctx, hash) 85 + if err != nil { 86 + if errors.Is(err, cache.ErrNotFound) { 87 + return "", cache.ErrNotFound 88 + } 89 + slog.ErrorContext(ctx, "failed to load params for hash", "hash", hash, "error", err) 90 + return "", err 91 + } 92 + return params.LocationHash(), nil 93 + } 94 + 95 + func (s *server) writeIngredientLoadError(w http.ResponseWriter, r *http.Request, err error) { 96 + switch { 97 + case errors.Is(err, cache.ErrNotFound): 98 + if _, paramsErr := recipes.IO(s.cache).ParamsFromCache(r.Context(), r.PathValue("hash")); errors.Is(paramsErr, cache.ErrNotFound) { 99 + http.Error(w, "parameters not found in cache", http.StatusNotFound) 100 + return 101 + } 102 + http.Error(w, "ingredients not found in cache", http.StatusNotFound) 103 + default: 104 + http.Error(w, "failed to fetch ingredients", http.StatusInternalServerError) 105 + } 106 + }
+3 -5
internal/ingredients/server_test.go
··· 7 7 "testing" 8 8 "time" 9 9 10 + "careme/internal/ai" 10 11 "careme/internal/cache" 11 - "careme/internal/kroger" 12 12 "careme/internal/locations" 13 13 "careme/internal/recipes" 14 14 ) ··· 24 24 t.Fatalf("SaveParams failed: %v", err) 25 25 } 26 26 27 - description := "Honeycrisp apple" 28 - entries := []kroger.Ingredient{{Description: &description}} 27 + entries := []ai.InputIngredient{{ProductID: "apple-1", Description: "Honeycrisp apple"}} 29 28 if err := rio.SaveIngredients(t.Context(), params.LocationHash(), entries); err != nil { 30 29 t.Fatalf("SaveIngredients failed: %v", err) 31 30 } ··· 59 58 t.Fatalf("SaveParams failed: %v", err) 60 59 } 61 60 62 - description := "Broccoli" 63 - entries := []kroger.Ingredient{{Description: &description}} 61 + entries := []ai.InputIngredient{{ProductID: "broccoli-1", Description: "Broccoli"}} 64 62 if err := rio.SaveIngredients(t.Context(), params.LocationHash(), entries); err != nil { 65 63 t.Fatalf("SaveIngredients failed: %v", err) 66 64 }
+30 -7
internal/kroger/client.gen.go
··· 1044 1044 } `json:"meta,omitempty"` 1045 1045 } 1046 1046 JSON400 *struct { 1047 - Errors *string `json:"errors,omitempty"` 1047 + Errors *struct { 1048 + Code *string `json:"code,omitempty"` 1049 + Reason *string `json:"reason,omitempty"` 1050 + Timestamp *float32 `json:"timestamp,omitempty"` 1051 + } `json:"errors,omitempty"` 1048 1052 } 1049 1053 JSON401 *struct { 1050 1054 Errors *struct { ··· 1386 1390 } `json:"meta,omitempty"` 1387 1391 } 1388 1392 JSON400 *struct { 1389 - Errors *string `json:"errors,omitempty"` 1393 + Errors *struct { 1394 + Code *string `json:"code,omitempty"` 1395 + Reason *string `json:"reason,omitempty"` 1396 + Timestamp *float32 `json:"timestamp,omitempty"` 1397 + } `json:"errors,omitempty"` 1390 1398 } 1391 1399 JSON401 *struct { 1392 1400 Errors *struct { ··· 1635 1643 } `json:"meta,omitempty"` 1636 1644 } 1637 1645 JSON400 *struct { 1638 - Errors *string `json:"errors,omitempty"` 1646 + Errors *struct { 1647 + Code *string `json:"code,omitempty"` 1648 + Reason *string `json:"reason,omitempty"` 1649 + Timestamp *float32 `json:"timestamp,omitempty"` 1650 + } `json:"errors,omitempty"` 1639 1651 } 1640 1652 JSON401 *struct { 1641 1653 Errors *struct { ··· 1953 1965 1954 1966 case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: 1955 1967 var dest struct { 1956 - Errors *string `json:"errors,omitempty"` 1968 + Errors *struct { 1969 + Code *string `json:"code,omitempty"` 1970 + Reason *string `json:"reason,omitempty"` 1971 + Timestamp *float32 `json:"timestamp,omitempty"` 1972 + } `json:"errors,omitempty"` 1957 1973 } 1958 1974 if err := json.Unmarshal(bodyBytes, &dest); err != nil { 1959 1975 return nil, err ··· 2393 2409 2394 2410 case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: 2395 2411 var dest struct { 2396 - Errors *string `json:"errors,omitempty"` 2412 + Errors *struct { 2413 + Code *string `json:"code,omitempty"` 2414 + Reason *string `json:"reason,omitempty"` 2415 + Timestamp *float32 `json:"timestamp,omitempty"` 2416 + } `json:"errors,omitempty"` 2397 2417 } 2398 2418 if err := json.Unmarshal(bodyBytes, &dest); err != nil { 2399 2419 return nil, err ··· 2686 2706 2687 2707 case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: 2688 2708 var dest struct { 2689 - Errors *string `json:"errors,omitempty"` 2709 + Errors *struct { 2710 + Code *string `json:"code,omitempty"` 2711 + Reason *string `json:"reason,omitempty"` 2712 + Timestamp *float32 `json:"timestamp,omitempty"` 2713 + } `json:"errors,omitempty"` 2690 2714 } 2691 2715 if err := json.Unmarshal(bodyBytes, &dest); err != nil { 2692 - fmt.Printf("failing at 400: %s\n", bodyBytes) 2693 2716 return nil, err 2694 2717 } 2695 2718 response.JSON400 = &dest
+20 -1
internal/kroger/staples.go
··· 83 83 if err != nil { 84 84 return nil, fmt.Errorf("kroger product search request failed: %w", err) 85 85 } 86 - if err := requireSuccess(products.StatusCode(), products.JSON500); err != nil { 86 + if err := requireSuccess(products.StatusCode(), productSearchErrorPayload(products)); err != nil { 87 87 return nil, err 88 88 } 89 89 ··· 198 198 return nil 199 199 } 200 200 return krogerError(statusCode, payload) 201 + } 202 + 203 + func productSearchErrorPayload(resp *ProductSearchResponse) any { 204 + if resp == nil { 205 + return nil 206 + } 207 + if resp.JSON400 != nil { 208 + return resp.JSON400 209 + } 210 + if resp.JSON401 != nil { 211 + return resp.JSON401 212 + } 213 + if resp.JSON500 != nil { 214 + return resp.JSON500 215 + } 216 + if len(resp.Body) != 0 { 217 + return json.RawMessage(resp.Body) 218 + } 219 + return nil 201 220 } 202 221 203 222 func mustJSONSignature(value any) string {
+58 -1
internal/kroger/staples_test.go
··· 1 1 package kroger 2 2 3 - import "testing" 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "strings" 7 + "testing" 8 + ) 4 9 5 10 func TestIdentityProviderSignature_UsesJSONStaples(t *testing.T) { 6 11 got := NewIdentityProvider().Signature() ··· 10 15 t.Fatalf("unexpected signature: got %q want %q", got, want) 11 16 } 12 17 } 18 + 19 + func TestParseProductSearchResponse_DecodesStructuredJSON400(t *testing.T) { 20 + rsp := httptest.NewRecorder() 21 + rsp.Header().Set("Content-Type", "application/json") 22 + rsp.WriteHeader(http.StatusBadRequest) 23 + _, _ = rsp.WriteString(`{"errors":{"timestamp":1776969026460,"code":"PRODUCT-2011","reason":"Field 'locationId' must have a length of 8 alphanumeric characters"}}`) 24 + 25 + parsed, err := ParseProductSearchResponse(rsp.Result()) 26 + if err != nil { 27 + t.Fatalf("ParseProductSearchResponse returned error: %v", err) 28 + } 29 + if parsed.JSON400 == nil || parsed.JSON400.Errors == nil { 30 + t.Fatalf("expected JSON400 error payload, got %+v", parsed.JSON400) 31 + } 32 + if got, want := toStr(parsed.JSON400.Errors.Code), "PRODUCT-2011"; got != want { 33 + t.Fatalf("unexpected error code: got %q want %q", got, want) 34 + } 35 + if got := toStr(parsed.JSON400.Errors.Reason); !strings.Contains(got, "length of 8") { 36 + t.Fatalf("unexpected error reason: %q", got) 37 + } 38 + } 39 + 40 + func TestProductSearchErrorPayloadPrefersDecodedJSON400(t *testing.T) { 41 + code := "PRODUCT-2011" 42 + reason := "Field 'locationId' must have a length of 8 alphanumeric characters" 43 + resp := &ProductSearchResponse{ 44 + JSON400: &struct { 45 + Errors *struct { 46 + Code *string `json:"code,omitempty"` 47 + Reason *string `json:"reason,omitempty"` 48 + Timestamp *float32 `json:"timestamp,omitempty"` 49 + } `json:"errors,omitempty"` 50 + }{ 51 + Errors: &struct { 52 + Code *string `json:"code,omitempty"` 53 + Reason *string `json:"reason,omitempty"` 54 + Timestamp *float32 `json:"timestamp,omitempty"` 55 + }{ 56 + Code: &code, 57 + Reason: &reason, 58 + }, 59 + }, 60 + } 61 + 62 + payload := productSearchErrorPayload(resp) 63 + if payload == nil { 64 + t.Fatal("expected non-nil payload") 65 + } 66 + if !strings.Contains(krogerError(http.StatusBadRequest, payload).Error(), "PRODUCT-2011") { 67 + t.Fatalf("expected krogerError to include decoded payload, got %v", krogerError(http.StatusBadRequest, payload)) 68 + } 69 + }
-47
internal/kroger/type.go
··· 1 1 package kroger 2 2 3 - import ( 4 - "encoding/csv" 5 - "fmt" 6 - "io" 7 - ) 8 - 9 3 // this is a subset of ProductSearchResponse200Data combining item and product we think will be useful 10 4 // TODO merge with ai.Ingredient 11 5 type Ingredient struct { ··· 22 16 // not used by llm. 23 17 Categories *[]string `json:"categories,omitempty"` 24 18 // Figure out what is in taxonomies 25 - } 26 - 27 - // this is what we'll actually pass to the llm 28 - func ToTSV(ingredient []Ingredient, w io.Writer) error { 29 - csvw := csv.NewWriter(w) 30 - csvw.Comma = '\t' 31 - header := []string{"ProductId", "AisleNumber", "Brand", "Description", "Size", "PriceRegular", "PriceSale"} 32 - if err := csvw.Write(header); err != nil { 33 - return err 34 - } 35 - for _, i := range ingredient { 36 - if i.PriceSale == nil { 37 - i.PriceSale = i.PriceRegular 38 - } 39 - row := []string{ 40 - toStr(i.ProductId), 41 - toStr(i.AisleNumber), 42 - toStr(i.Brand), 43 - toStr(i.Description), 44 - toStr(i.Size), 45 - floatToStr(i.PriceRegular), 46 - floatToStr(i.PriceSale), 47 - // todo add a dicount? 48 - } 49 - if len(header) != len(row) { 50 - return fmt.Errorf("header and row length mismatch: %d vs %d", len(header), len(row)) 51 - } 52 - if err := csvw.Write(row); err != nil { 53 - return err 54 - } 55 - } 56 - csvw.Flush() 57 - return csvw.Error() 58 - } 59 - 60 - // toStr returns the string value if non-nil, or "empty" otherwise. 61 - func floatToStr(f *float32) string { 62 - if f == nil { 63 - return "" 64 - } 65 - return fmt.Sprintf("%.2f", *f) 66 19 } 67 20 68 21 func toStr(s *string) string {
-42
internal/kroger/types_test.go
··· 1 - package kroger 2 - 3 - import ( 4 - "strings" 5 - "testing" 6 - 7 - "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" 8 - ) 9 - 10 - func TestEncodeSaleIngredientsToTS(t *testing.T) { 11 - brand := "Foster Farms" 12 - description := "Fresh chicken thighs" 13 - 14 - var sb strings.Builder 15 - err := ToTSV([]Ingredient{ 16 - { 17 - ProductId: to.Ptr("444"), 18 - Brand: &brand, 19 - Description: &description, 20 - AisleNumber: to.Ptr("7"), 21 - PriceRegular: to.Ptr(float32(3.50)), // should be omitted due to omitempty 22 - PriceSale: nil, // should be omitted due to omitempty 23 - }, 24 - }, &sb) 25 - encoded := sb.String() 26 - if err != nil { 27 - t.Fatalf("unexpected encode error: %v", err) 28 - } 29 - 30 - if strings.Contains(encoded, "omitempty") { 31 - t.Fatalf("encoded payload should not contain omitempty tags: %s", encoded) 32 - } 33 - if strings.Contains(encoded, "null") { 34 - t.Fatalf("encoded payload should not include null values: %s", encoded) 35 - } 36 - if !strings.Contains(encoded, "Brand") || !strings.Contains(encoded, "Description") { 37 - t.Fatalf("encoded payload should include expected keys:\n %s", encoded) 38 - } 39 - if strings.Contains(encoded, "favorite") { 40 - t.Fatalf("encoded payload should omit nil fields with omitempty: %s", encoded) 41 - } 42 - }
+3 -1
internal/mail/mail.go
··· 18 18 "careme/internal/ai" 19 19 "careme/internal/cache" 20 20 "careme/internal/config" 21 + ingredientgrading "careme/internal/ingredients/grading" 21 22 "careme/internal/locations" 22 23 "careme/internal/recipes" 23 24 "careme/internal/recipes/critique" ··· 73 74 74 75 userStorage := users.NewStorage(cache) 75 76 mc := critique.NewManager(cfg, cache) 76 - staples, err := recipes.NewCachedStaplesService(cfg, cache) 77 + ig := ingredientgrading.NewManager(cfg, cache) 78 + staples, err := recipes.NewCachedStaplesService(cfg, cache, ig) 77 79 if err != nil { 78 80 return nil, fmt.Errorf("failed to create staples service: %w", err) 79 81 }
+15 -21
internal/recipes/generator.go
··· 9 9 "time" 10 10 11 11 "careme/internal/ai" 12 - "careme/internal/kroger" 13 12 "careme/internal/locations" 14 13 "careme/internal/parallelism" 15 14 "careme/internal/recipes/critique" 16 15 "careme/internal/wholefoods" 17 16 18 17 "github.com/samber/lo" 18 + "github.com/samber/lo/mutable" 19 19 ) 20 20 21 21 type aiClient interface { 22 - GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) 22 + GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []ai.InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) 23 23 Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error) 24 24 AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) 25 25 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 26 - PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) 26 + PickWine(ctx context.Context, recipe ai.Recipe, wines []ai.InputIngredient) (*ai.WineSelection, error) 27 27 } 28 28 29 29 type staplesService interface { 30 - GetStaples(ctx context.Context, p *GeneratorParams) ([]kroger.Ingredient, error) 30 + FetchStaples(ctx context.Context, p *GeneratorParams) ([]ai.InputIngredient, error) 31 31 // only used for wine. Probably need a refactoro 32 - GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]kroger.Ingredient, error) 32 + GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]ai.InputIngredient, error) 33 33 } 34 34 35 35 type generatorService struct { ··· 74 74 return &ai.WineSelection{Commentary: "no wines styles for recipe", Wines: []ai.Ingredient{}}, nil 75 75 } 76 76 77 - wines, err := parallelism.Flatten(styles, func(style string) ([]kroger.Ingredient, error) { 77 + wines, err := parallelism.Flatten(styles, func(style string) ([]ai.InputIngredient, error) { 78 78 return g.staples.GetIngredients(ctx, location, style, 0, date) 79 79 }) 80 80 if err != nil { ··· 84 84 if len(wines) == 0 { 85 85 return &ai.WineSelection{Commentary: "no wines found", Wines: []ai.Ingredient{}}, nil 86 86 } 87 - wines = uniqueByDescription(wines) 87 + wines = lo.UniqBy(wines, func(i ai.InputIngredient) string { 88 + return i.ProductID 89 + }) 88 90 89 91 selection, err := g.aiClient.PickWine(ctx, recipe, wines) 90 92 if err != nil { ··· 123 125 } 124 126 125 127 slog.InfoContext(ctx, "Generating recipes for location", "location", p.String()) 126 - ingredients, err := g.staples.GetStaples(ctx, p) 128 + ingredients, err := g.staples.FetchStaples(ctx, p) 127 129 if err != nil { 128 130 return nil, fmt.Errorf("failed to get staples: %w", err) 129 131 } 130 132 g.writeStatus(ctx, hash, fmt.Sprintf("Looking through %d ingredients", len(ingredients))) 133 + ingredients = lo.Filter(ingredients, func(ing ai.InputIngredient, _ int) bool { 134 + // TODO make configurable? 135 + return ing.Grade == nil || ing.Grade.Score > 5 136 + }) 137 + mutable.Shuffle(ingredients) 131 138 132 139 instructions := []string{p.Directive, p.Instructions} 133 140 shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, instructions, p.Date, p.LastRecipes) ··· 156 163 157 164 func (g *generatorService) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { 158 165 return g.aiClient.GenerateRecipeImage(ctx, recipe) 159 - } 160 - 161 - func uniqueByDescription(ingredients []kroger.Ingredient) []kroger.Ingredient { 162 - return lo.UniqBy(ingredients, func(i kroger.Ingredient) string { 163 - return toStr(i.Description) 164 - }) 165 - } 166 - 167 - func toStr(s *string) string { 168 - if s == nil { 169 - return "empty" 170 - } 171 - return *s 172 166 } 173 167 174 168 func newlySaved(saved []ai.Recipe, priorSavedHashes []string) []string {
+38 -34
internal/recipes/generator_test.go
··· 10 10 11 11 "careme/internal/ai" 12 12 "careme/internal/cache" 13 - "careme/internal/kroger" 13 + ingredientgrading "careme/internal/ingredients/grading" 14 14 "careme/internal/locations" 15 15 "careme/internal/recipes/critique" 16 16 ··· 33 33 34 34 type captureGenerateAIClient struct { 35 35 shoppingList *ai.ShoppingList 36 + ingredients []ai.InputIngredient 36 37 } 37 38 38 39 type sequenceAIClient struct { ··· 56 57 type captureWineStaplesProvider struct { 57 58 mu sync.Mutex 58 59 searches []string 59 - responses map[string][]kroger.Ingredient 60 + responses map[string][]ai.InputIngredient 60 61 } 61 62 62 - func (c *captureWineQuestionAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 63 + func (c *captureWineQuestionAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []ai.InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 63 64 panic("unexpected call to GenerateRecipes") 64 65 } 65 66 ··· 76 77 panic("unexpected call to GenerateRecipeImage") 77 78 } 78 79 79 - func (c *captureWineQuestionAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 80 + func (c *captureWineQuestionAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []ai.InputIngredient) (*ai.WineSelection, error) { 80 81 c.recipe = recipe 81 82 if c.selection != nil { 82 83 return c.selection, nil ··· 91 92 return nil 92 93 } 93 94 94 - func (c *captureRegenerateAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 95 + func (c *captureRegenerateAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []ai.InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 95 96 panic("unexpected call to GenerateRecipes") 96 97 } 97 98 ··· 112 113 panic("unexpected call to GenerateRecipeImage") 113 114 } 114 115 115 - func (c *captureRegenerateAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 116 + func (c *captureRegenerateAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []ai.InputIngredient) (*ai.WineSelection, error) { 116 117 panic("unexpected call to PickWine") 117 118 } 118 119 ··· 120 121 return nil 121 122 } 122 123 123 - func (c *captureGenerateAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 124 + func (c *captureGenerateAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []ai.InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 125 + c.ingredients = append([]ai.InputIngredient(nil), ingredients...) 124 126 if c.shoppingList != nil { 125 127 return c.shoppingList, nil 126 128 } ··· 139 141 panic("unexpected call to GenerateRecipeImage") 140 142 } 141 143 142 - func (c *captureGenerateAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 144 + func (c *captureGenerateAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []ai.InputIngredient) (*ai.WineSelection, error) { 143 145 panic("unexpected call to PickWine") 144 146 } 145 147 ··· 147 149 return nil 148 150 } 149 151 150 - func (c *sequenceAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 152 + func (c *sequenceAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []ai.InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 151 153 c.mu.Lock() 152 154 defer c.mu.Unlock() 153 155 ··· 184 186 panic("unexpected call to GenerateRecipeImage") 185 187 } 186 188 187 - func (c *sequenceAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) { 189 + func (c *sequenceAIClient) PickWine(ctx context.Context, recipe ai.Recipe, wines []ai.InputIngredient) (*ai.WineSelection, error) { 188 190 panic("unexpected call to PickWine") 189 191 } 190 192 ··· 227 229 }, nil 228 230 } 229 231 230 - func (s *captureWineStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 232 + func (s *captureWineStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 231 233 panic("unexpected call to FetchStaples") 232 234 } 233 235 234 - func (s *captureWineStaplesProvider) GetIngredients(_ context.Context, _ string, searchTerm string, _ int) ([]kroger.Ingredient, error) { 236 + func (s *captureWineStaplesProvider) GetIngredients(_ context.Context, _ string, searchTerm string, _ int) ([]ai.InputIngredient, error) { 235 237 s.mu.Lock() 236 238 s.searches = append(s.searches, searchTerm) 237 239 s.mu.Unlock() ··· 261 263 262 264 cacheStore := cache.NewFileCache(t.TempDir()) 263 265 rio := IO(cacheStore) 264 - cached := []kroger.Ingredient{ 266 + cached := []ai.InputIngredient{ 265 267 { 266 - Description: loPtr("Cached Pinot Noir"), 267 - Size: loPtr("750mL"), 268 + ProductID: "cached-pinot-noir", 269 + Description: "Cached Pinot Noir", 270 + Size: "750mL", 268 271 }, 269 272 } 270 273 if err := rio.SaveIngredients(t.Context(), wineIngredientsCacheKey(style, location, cacheDate), cached); err != nil { ··· 318 321 }, 319 322 } 320 323 staplesStub := &captureWineStaplesProvider{ 321 - responses: map[string][]kroger.Ingredient{ 322 - "red-wine": {{Description: loPtr("Whole Foods Red")}}, 323 - "white-wine": {{Description: loPtr("Whole Foods White")}}, 324 - "sparkling": {{Description: loPtr("Whole Foods Bubbly")}}, 324 + responses: map[string][]ai.InputIngredient{ 325 + "red-wine": {{ProductID: "wholefoods-red", Description: "Whole Foods Red"}}, 326 + "white-wine": {{ProductID: "wholefoods-white", Description: "Whole Foods White"}}, 327 + "sparkling": {{ProductID: "wholefoods-bubbly", Description: "Whole Foods Bubbly"}}, 325 328 }, 326 329 } 330 + rio := IO(cache.NewFileCache(t.TempDir())) 327 331 g := &generatorService{ 328 - staples: &cachedStaplesService{cache: IO(cache.NewFileCache(t.TempDir())), provider: staplesStub}, 332 + staples: &cachedStaplesService{cache: rio, provider: staplesStub}, 329 333 aiClient: aiStub, 330 334 } 331 335 ··· 407 411 cacheStore := cache.NewFileCache(t.TempDir()) 408 412 io := IO(cacheStore) 409 413 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 410 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 414 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 411 415 t.Fatalf("failed to seed ingredients cache: %v", err) 412 416 } 413 417 ··· 419 423 } 420 424 critiquer := &captureCritiqueService{} 421 425 g := &generatorService{ 422 - staples: &cachedStaplesService{cache: io}, 426 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 423 427 aiClient: aiStub, 424 428 critiquer: critiquer, 425 429 statusWriter: noopstatuswriter{}, ··· 478 482 cacheStore := cache.NewFileCache(t.TempDir()) 479 483 io := IO(cacheStore) 480 484 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 481 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 485 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 482 486 t.Fatalf("failed to seed ingredients cache: %v", err) 483 487 } 484 488 ··· 521 525 } 522 526 523 527 g := &generatorService{ 524 - staples: &cachedStaplesService{cache: io}, 528 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 525 529 aiClient: aiStub, 526 530 critiquer: critiquer, 527 531 statusWriter: noopstatuswriter{}, ··· 566 570 cacheStore := cache.NewFileCache(t.TempDir()) 567 571 io := IO(cacheStore) 568 572 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 569 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 573 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 570 574 t.Fatalf("failed to seed ingredients cache: %v", err) 571 575 } 572 576 ··· 608 612 }, 609 613 } 610 614 g := &generatorService{ 611 - staples: &cachedStaplesService{cache: io}, 615 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 612 616 aiClient: aiStub, 613 617 critiquer: critiquer, 614 618 statusWriter: noopstatuswriter{}, ··· 632 636 cacheStore := cache.NewFileCache(t.TempDir()) 633 637 io := IO(cacheStore) 634 638 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 635 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 639 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 636 640 t.Fatalf("failed to seed ingredients cache: %v", err) 637 641 } 638 642 ··· 643 647 }}, 644 648 } 645 649 g := &generatorService{ 646 - staples: &cachedStaplesService{cache: io}, 650 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 647 651 aiClient: aiStub, 648 652 critiquer: &captureCritiqueService{}, 649 653 statusWriter: noopstatuswriter{}, ··· 676 680 cacheStore := cache.NewFileCache(t.TempDir()) 677 681 io := IO(cacheStore) 678 682 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 679 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 683 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 680 684 t.Fatalf("failed to seed ingredients cache: %v", err) 681 685 } 682 686 683 687 statuses := &statusCounter{} 684 688 g := &generatorService{ 685 - staples: &cachedStaplesService{cache: io}, 689 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 686 690 aiClient: &sequenceAIClient{generateResponses: []*ai.ShoppingList{{ResponseID: "resp-stable", Recipes: []ai.Recipe{steady}}}}, 687 691 critiquer: &captureCritiqueService{}, 688 692 statusWriter: statuses, ··· 847 851 cacheStore := cache.NewFileCache(t.TempDir()) 848 852 io := IO(cacheStore) 849 853 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 850 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 854 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 851 855 t.Fatalf("failed to seed ingredients cache: %v", err) 852 856 } 853 857 ··· 886 890 }, 887 891 } 888 892 g := &generatorService{ 889 - staples: &cachedStaplesService{cache: io}, 893 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 890 894 aiClient: aiStub, 891 895 critiquer: critiquer, 892 896 statusWriter: noopstatuswriter{}, ··· 914 918 cacheStore := cache.NewFileCache(t.TempDir()) 915 919 io := IO(cacheStore) 916 920 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 917 - if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 921 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []ai.InputIngredient{{ProductID: "chicken-1", Description: "Chicken"}}); err != nil { 918 922 t.Fatalf("failed to seed ingredients cache: %v", err) 919 923 } 920 924 ··· 941 945 }, 942 946 } 943 947 g := &generatorService{ 944 - staples: &cachedStaplesService{cache: io}, 948 + staples: &cachedStaplesService{cache: io, grader: ingredientgrading.NewManager(nil, nil)}, 945 949 aiClient: aiStub, 946 950 critiquer: critiquer, 947 951 statusWriter: noopstatuswriter{},
+4 -5
internal/recipes/io.go
··· 9 9 10 10 "careme/internal/ai" 11 11 "careme/internal/cache" 12 - "careme/internal/kroger" 13 12 "careme/internal/parallelism" 14 13 "careme/internal/recipes/feedback" 15 14 ··· 97 96 return &params, nil 98 97 } 99 98 100 - func (rio recipeio) IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) { 101 - // honor legacy hashes? I don't think so gets converted in server 99 + func (rio recipeio) IngredientsFromCache(ctx context.Context, hash string) ([]ai.InputIngredient, error) { 102 100 primaryKey := ingredientsCachePrefix + hash 103 101 ingredientBlob, err := rio.Cache.Get(ctx, primaryKey) 104 102 if err != nil { ··· 110 108 } 111 109 }() 112 110 113 - var ingredients []kroger.Ingredient 111 + // this should be back compat with kroger.Ingredient 112 + var ingredients []ai.InputIngredient 114 113 if err := json.NewDecoder(ingredientBlob).Decode(&ingredients); err != nil { 115 114 return nil, err 116 115 } 117 116 return ingredients, nil 118 117 } 119 118 120 - func (rio recipeio) SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error { 119 + func (rio recipeio) SaveIngredients(ctx context.Context, hash string, ingredients []ai.InputIngredient) error { 121 120 ingredientsJSON, err := json.Marshal(ingredients) 122 121 if err != nil { 123 122 return err
+37 -5
internal/recipes/io_test.go
··· 10 10 11 11 "careme/internal/ai" 12 12 "careme/internal/cache" 13 - "careme/internal/kroger" 14 13 "careme/internal/locations" 15 14 ) 16 15 ··· 179 178 rio := IO(cacheStore) 180 179 181 180 hash := "ingredient-hash" 182 - ingredients := []kroger.Ingredient{ 181 + ingredients := []ai.InputIngredient{ 183 182 { 184 - Description: loPtr("Chicken Breast"), 185 - Size: loPtr("1 lb"), 183 + ProductID: "chicken-1", 184 + Description: "Chicken Breast", 185 + Size: "1 lb", 186 186 }, 187 187 } 188 188 ··· 201 201 if err != nil { 202 202 t.Fatalf("IngredientsFromCache failed: %v", err) 203 203 } 204 - if len(got) != 1 || got[0].Description == nil || *got[0].Description != "Chicken Breast" { 204 + if len(got) != 1 || got[0].Description != "Chicken Breast" { 205 205 t.Fatalf("unexpected ingredients payload: %+v", got) 206 + } 207 + } 208 + 209 + func TestSaveIngredients_PreservesGrade(t *testing.T) { 210 + tmpDir := t.TempDir() 211 + cacheStore := cache.NewFileCache(tmpDir) 212 + rio := IO(cacheStore) 213 + 214 + hash := "ingredient-input-hash" 215 + ingredients := []ai.InputIngredient{ 216 + { 217 + ProductID: "chicken-1", 218 + Description: "Chicken Breast", 219 + Size: "1 lb", 220 + Grade: &ai.IngredientGrade{Score: 9, Reason: "Fresh and flexible."}, 221 + }, 222 + } 223 + 224 + if err := rio.SaveIngredients(t.Context(), hash, ingredients); err != nil { 225 + t.Fatalf("SaveIngredients failed: %v", err) 226 + } 227 + 228 + if _, err := os.Stat(filepath.Join(tmpDir, ingredientsCachePrefix, hash)); err != nil { 229 + t.Fatalf("expected ingredients at prefixed key: %v", err) 230 + } 231 + 232 + got, err := rio.IngredientsFromCache(t.Context(), hash) 233 + if err != nil { 234 + t.Fatalf("IngredientsFromCache failed: %v", err) 235 + } 236 + if len(got) != 1 || got[0].Description != "Chicken Breast" || got[0].Grade == nil || got[0].Grade.Score != 9 { 237 + t.Fatalf("unexpected input ingredients payload: %+v", got) 206 238 } 207 239 } 208 240
+152 -44
internal/recipes/staples.go
··· 12 12 "testing" 13 13 "time" 14 14 15 + "careme/internal/ai" 15 16 "careme/internal/albertsons" 16 17 "careme/internal/brightdata" 17 18 "careme/internal/cache" ··· 22 23 "careme/internal/wholefoods" 23 24 24 25 "github.com/samber/lo" 25 - "github.com/samber/lo/mutable" 26 26 ) 27 27 28 28 // todo make this a indepenedent ingredient object not kroger. 29 - type staplesProvider interface { 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 { 30 32 FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 31 33 GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) 32 34 } ··· 36 38 Signature() string 37 39 } 38 40 39 - type routingStaplesProvider struct { 40 - backends []backendStaplesProvider 41 - } 42 - 43 41 type backendStaplesProvider interface { 44 - IsID(locationID string) bool 45 - Signature() string 46 - staplesProvider 42 + identityProvider 43 + krogerProvider 47 44 } 48 45 49 - type ingredientio interface { 50 - SaveIngredients(ctx context.Context, hash string, ingredients []kroger.Ingredient) error 51 - IngredientsFromCache(ctx context.Context, hash string) ([]kroger.Ingredient, error) 52 - } 53 - 54 - type cachedStaplesService struct { 55 - provider staplesProvider 56 - cache ingredientio 46 + type routingStaplesProvider struct { 47 + backends []backendStaplesProvider 57 48 } 58 49 59 50 func NewStaplesProvider(cfg *config.Config) (staplesProvider, error) { ··· 66 57 return nil, err 67 58 } 68 59 69 - return routingStaplesProvider{ 60 + return convertingProvider{routingStaplesProvider{ 70 61 backends: backends, 71 - }, nil 72 - } 73 - 74 - func NewCachedStaplesService(cfg *config.Config, c cache.Cache) (*cachedStaplesService, error) { 75 - provider, err := NewStaplesProvider(cfg) 76 - if err != nil { 77 - return nil, fmt.Errorf("failed to create staples provider: %w", err) 78 - } 79 - return &cachedStaplesService{ 80 - provider: provider, 81 - cache: IO(c), 82 - }, nil 62 + }}, nil 83 63 } 84 64 85 65 func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { ··· 98 78 return provider.GetIngredients(ctx, locationID, searchTerm, skip) 99 79 } 100 80 101 - func (s *cachedStaplesService) GetStaples(ctx context.Context, p *GeneratorParams) ([]kroger.Ingredient, error) { 81 + type ingredientio interface { 82 + SaveIngredients(ctx context.Context, hash string, ingredients []ai.InputIngredient) error 83 + IngredientsFromCache(ctx context.Context, hash string) ([]ai.InputIngredient, error) 84 + } 85 + 86 + type grader interface { 87 + GradeIngredients(ctx context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) 88 + } 89 + 90 + type cachedStaplesService struct { 91 + provider staplesProvider 92 + cache ingredientio 93 + grader grader 94 + } 95 + 96 + type staplesProvider interface { 97 + FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) 98 + GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) 99 + } 100 + 101 + type convertingProvider struct { 102 + kprovider krogerProvider 103 + } 104 + 105 + var _ staplesProvider = convertingProvider{} 106 + 107 + func (cp convertingProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) { 108 + ingredients, err := cp.kprovider.FetchStaples(ctx, locationID) 109 + if err != nil { 110 + return nil, err 111 + } 112 + inputs := make([]ai.InputIngredient, 0, len(ingredients)) 113 + for _, ingredient := range ingredients { 114 + input, err := inputIngredientFromKrogerIngredient(ingredient) 115 + if err != nil { 116 + return nil, err 117 + } 118 + inputs = append(inputs, input) 119 + } 120 + 121 + inputs = lo.UniqBy(inputs, func(i ai.InputIngredient) string { 122 + return i.ProductID 123 + }) 124 + return inputs, nil 125 + } 126 + 127 + func (cp convertingProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) { 128 + ingredients, err := cp.kprovider.GetIngredients(ctx, locationID, searchTerm, skip) 129 + if err != nil { 130 + return nil, err 131 + } 132 + inputs := make([]ai.InputIngredient, 0, len(ingredients)) 133 + for _, ingredient := range ingredients { 134 + input, err := inputIngredientFromKrogerIngredient(ingredient) 135 + if err != nil { 136 + return nil, err 137 + } 138 + inputs = append(inputs, input) 139 + } 140 + 141 + inputs = lo.UniqBy(inputs, func(i ai.InputIngredient) string { 142 + return i.ProductID 143 + }) 144 + return inputs, nil 145 + } 146 + 147 + func inputIngredientFromKrogerIngredient(ingredient kroger.Ingredient) (ai.InputIngredient, error) { 148 + item := ai.InputIngredient{ 149 + ProductID: strings.TrimSpace(toStr(ingredient.ProductId)), 150 + AisleNumber: strings.TrimSpace(toStr(ingredient.AisleNumber)), 151 + Brand: strings.TrimSpace(toStr(ingredient.Brand)), 152 + Description: strings.TrimSpace(toStr(ingredient.Description)), 153 + Size: strings.TrimSpace(toStr(ingredient.Size)), 154 + PriceRegular: clonePrice(ingredient.PriceRegular), 155 + PriceSale: clonePrice(ingredient.PriceSale), 156 + Categories: categoriesFromPtr(ingredient.Categories), 157 + } 158 + item = ai.NormalizeInputIngredient(item) 159 + if item.ProductID == "" { 160 + return ai.InputIngredient{}, fmt.Errorf("ingredient product_id is required for %q", toStr(ingredient.Description)) 161 + } 162 + return item, nil 163 + } 164 + 165 + func toStr(ptr *string) string { 166 + if ptr == nil { 167 + return "" 168 + } 169 + return *ptr 170 + } 171 + 172 + func categoriesFromPtr(ptr *[]string) []string { 173 + if ptr == nil { 174 + return nil 175 + } 176 + return append([]string(nil), (*ptr)...) 177 + } 178 + 179 + func clonePrice(price *float32) *float32 { 180 + if price == nil { 181 + return nil 182 + } 183 + value := *price 184 + return &value 185 + } 186 + 187 + func NewCachedStaplesService(cfg *config.Config, c cache.Cache, grader grader) (*cachedStaplesService, error) { 188 + provider, err := NewStaplesProvider(cfg) 189 + if err != nil { 190 + return nil, fmt.Errorf("failed to create staples provider: %w", err) 191 + } 192 + rio := IO(c) 193 + return &cachedStaplesService{ 194 + provider: provider, 195 + cache: rio, 196 + grader: grader, 197 + }, nil 198 + } 199 + 200 + func (s *cachedStaplesService) FetchStaples(ctx context.Context, p *GeneratorParams) ([]ai.InputIngredient, error) { 102 201 lochash := p.LocationHash() 202 + locationID := p.Location.ID 103 203 104 - if cachedIngredients, err := s.cache.IngredientsFromCache(ctx, lochash); err == nil { 105 - slog.InfoContext(ctx, "serving cached ingredients", "location", p.String(), "hash", lochash, "count", len(cachedIngredients)) 106 - return cachedIngredients, nil 107 - } else if !errors.Is(err, cache.ErrNotFound) { 108 - slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 204 + cachedIngredients, err := s.cache.IngredientsFromCache(ctx, lochash) 205 + if err == nil { 206 + slog.InfoContext(ctx, "serving cached ingredients", "location", locationID, "hash", lochash, "count", len(cachedIngredients)) 207 + return s.grader.GradeIngredients(ctx, cachedIngredients) 208 + // shoulld we save? 209 + } 210 + 211 + if !errors.Is(err, cache.ErrNotFound) { 212 + slog.ErrorContext(ctx, "failed to read cached ingredients", "location", locationID, "error", err) 213 + } 214 + 215 + ingredients, err := s.provider.FetchStaples(ctx, locationID) 216 + if err != nil { 217 + return nil, fmt.Errorf("failed to get ingredients for staples for %s: %w", locationID, err) 109 218 } 110 219 111 - ingredients, err := s.provider.FetchStaples(ctx, p.Location.ID) 220 + graded, err := s.grader.GradeIngredients(ctx, ingredients) 112 221 if err != nil { 113 - return nil, fmt.Errorf("failed to get ingredients for staples for %s: %w", p.Location.ID, err) 222 + slog.ErrorContext(ctx, "failed to grade cached staples", "error", err) 223 + return nil, err 114 224 } 115 - ingredients = uniqueByDescription(ingredients) 116 - mutable.Shuffle(ingredients) 117 225 118 - if err := s.cache.SaveIngredients(ctx, lochash, ingredients); err != nil { 226 + if err := s.cache.SaveIngredients(ctx, lochash, graded); err != nil { 119 227 slog.ErrorContext(ctx, "failed to cache ingredients", "location", p.String(), "error", err) 120 228 return nil, err 121 229 } 122 - return ingredients, nil 230 + return graded, nil 123 231 } 124 232 125 233 // this is not actually wine specificexcept that GetIngredients only does wine requests from ui ··· 133 241 return "wines/" + base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 134 242 } 135 243 136 - func (s *cachedStaplesService) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]kroger.Ingredient, error) { 244 + func (s *cachedStaplesService) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]ai.InputIngredient, error) { 137 245 cacheKey := wineIngredientsCacheKey(searchTerm, locationID, date) 138 246 logger := slog.With("location", locationID, "date", date.Format("2006-01-02"), "style", searchTerm) 139 247 ··· 166 274 "starmarket_3566", 167 275 "acmemarkets_806", 168 276 } 169 - _, err := parallelism.Flatten(storeIDs, func(storeID string) ([]kroger.Ingredient, error) { 277 + _, err := parallelism.Flatten(storeIDs, func(storeID string) ([]ai.InputIngredient, error) { 170 278 return s.provider.FetchStaples(ctx, storeID) 171 279 }) 172 280 return err
+143 -13
internal/recipes/staples_test.go
··· 3 3 import ( 4 4 "context" 5 5 "slices" 6 + "strings" 6 7 "testing" 7 8 "time" 8 9 10 + "careme/internal/ai" 9 11 "careme/internal/albertsons" 10 12 "careme/internal/cache" 11 13 "careme/internal/kroger" ··· 40 42 } 41 43 42 44 type stubRoutingStaplesProvider struct { 43 - ingredients []kroger.Ingredient 45 + ingredients []ai.InputIngredient 44 46 err error 45 47 calls int 46 48 } 47 49 48 - func (s *stubRoutingStaplesProvider) FetchStaples(_ context.Context, _ string) ([]kroger.Ingredient, error) { 50 + type stubIngredientGrader struct { 51 + ingredients []ai.InputIngredient 52 + fn func([]ai.InputIngredient) ([]ai.InputIngredient, error) 53 + err error 54 + } 55 + 56 + func (s *stubRoutingStaplesProvider) FetchStaples(_ context.Context, _ string) ([]ai.InputIngredient, error) { 49 57 s.calls++ 50 58 if s.err != nil { 51 59 return nil, s.err ··· 53 61 return slices.Clone(s.ingredients), nil 54 62 } 55 63 56 - func (s *stubRoutingStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]kroger.Ingredient, error) { 64 + func (s *stubRoutingStaplesProvider) GetIngredients(_ context.Context, _ string, _ string, _ int) ([]ai.InputIngredient, error) { 57 65 s.calls++ 58 66 if s.err != nil { 59 67 return nil, s.err ··· 61 69 return slices.Clone(s.ingredients), nil 62 70 } 63 71 72 + func (s *stubIngredientGrader) GradeIngredients(_ context.Context, ingredients []ai.InputIngredient) ([]ai.InputIngredient, error) { 73 + s.ingredients = append([]ai.InputIngredient(nil), ingredients...) 74 + results := make([]ai.InputIngredient, 0, len(ingredients)) 75 + if s.err != nil { 76 + return nil, s.err 77 + } 78 + if s.fn != nil { 79 + return s.fn(ingredients) 80 + } 81 + for _, ingredient := range ingredients { 82 + ingredient.Grade = &ai.IngredientGrade{Score: 10, Reason: "stub"} 83 + results = append(results, ingredient) 84 + } 85 + return results, nil 86 + } 87 + 64 88 func TestRoutingStaplesProvider_SelectsProviderByLocationID(t *testing.T) { 65 89 krogerProvider := &stubStaplesProvider{ids: map[string]bool{"70100023": true}} 66 90 wholeFoodsProvider := &stubStaplesProvider{ids: map[string]bool{"wholefoods_10216": true}} ··· 125 149 } 126 150 } 127 151 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 + }) 166 + if err != nil { 167 + t.Fatalf("inputIngredientFromKrogerIngredient returned error: %v", err) 168 + } 169 + 170 + if ingredient.ProductID != "apple-1" { 171 + t.Fatalf("unexpected product id: %+v", ingredient) 172 + } 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) 175 + } 176 + if ingredient.PriceRegular == nil || *ingredient.PriceRegular != regular { 177 + t.Fatalf("unexpected regular price: %+v", ingredient.PriceRegular) 178 + } 179 + if ingredient.PriceSale == nil || *ingredient.PriceSale != sale { 180 + t.Fatalf("unexpected sale price: %+v", ingredient.PriceSale) 181 + } 182 + if !slices.Equal(ingredient.Categories, categories) { 183 + t.Fatalf("unexpected categories: got %v want %v", ingredient.Categories, categories) 184 + } 185 + } 186 + 187 + func TestInputIngredientFromKrogerIngredientRejectsBlankProductID(t *testing.T) { 188 + _, err := inputIngredientFromKrogerIngredient(kroger.Ingredient{Description: loPtr("Asparagus")}) 189 + if err == nil { 190 + t.Fatal("expected blank product id error") 191 + } 192 + if !strings.Contains(err.Error(), "product_id is required") { 193 + t.Fatalf("unexpected error: %v", err) 194 + } 195 + } 196 + 128 197 func TestStaplesSignatureForLocation_UsesAlbertsonsIdentityProvider(t *testing.T) { 129 198 t.Parallel() 130 199 ··· 135 204 } 136 205 } 137 206 138 - func TestGetStaples_UsesProviderAndCachesWholeFoodsResults(t *testing.T) { 207 + func TestFetchStaples_UsesProviderAndCachesWholeFoodsResults(t *testing.T) { 139 208 cacheStore := cache.NewFileCache(t.TempDir()) 140 - provider := &stubRoutingStaplesProvider{ 209 + provider := &stubStaplesProvider{ 210 + ids: map[string]bool{"wholefoods_10216": true}, 141 211 ingredients: []kroger.Ingredient{ 142 - {Description: loPtr("Honeycrisp Apple")}, 143 - {Description: loPtr("Honeycrisp Apple")}, 144 - {Description: loPtr("Baby Spinach")}, 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")}, 145 215 }, 146 216 } 147 217 s := &cachedStaplesService{ 148 218 cache: IO(cacheStore), 149 - provider: provider, 219 + provider: convertingProvider{kprovider: provider}, 220 + grader: &stubIngredientGrader{}, 150 221 } 151 222 152 223 params := &generatorParams{ ··· 154 225 Date: time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC), 155 226 } 156 227 157 - got, err := s.GetStaples(t.Context(), params) 228 + got, err := s.FetchStaples(t.Context(), params) 158 229 if err != nil { 159 - t.Fatalf("GetStaples returned error: %v", err) 230 + t.Fatalf("FetchStaples returned error: %v", err) 160 231 } 161 232 if provider.calls != 1 { 162 233 t.Fatalf("expected provider to be called once, got %d", provider.calls) ··· 164 235 if len(got) != 2 { 165 236 t.Fatalf("expected deduped results, got %d", len(got)) 166 237 } 238 + if got[0].ProductID == "" { 239 + t.Fatalf("expected input ingredient product id, got %+v", got) 240 + } 167 241 168 242 cached, err := IO(cacheStore).IngredientsFromCache(t.Context(), params.LocationHash()) 169 243 if err != nil { ··· 173 247 t.Fatalf("expected cached deduped results, got %d", len(cached)) 174 248 } 175 249 176 - gotAgain, err := s.GetStaples(t.Context(), params) 250 + gotAgain, err := s.FetchStaples(t.Context(), params) 177 251 if err != nil { 178 - t.Fatalf("GetStaples returned error on cached call: %v", err) 252 + t.Fatalf("FetchStaples returned error on cached call: %v", err) 179 253 } 180 254 if provider.calls != 1 { 181 255 t.Fatalf("expected cached call to skip provider, got %d calls", provider.calls) ··· 184 258 t.Fatalf("expected cached results, got %d", len(gotAgain)) 185 259 } 186 260 } 261 + 262 + func TestFetchStaples_GradesCachedIngredientsBeforeReturning(t *testing.T) { 263 + cacheStore := cache.NewInMemoryCache() 264 + grader := &stubIngredientGrader{} 265 + steak := ai.InputIngredient{ProductID: "steak-1", Description: "Ribeye Steak"} 266 + chips := ai.InputIngredient{ProductID: "chips-1", Description: "Potato Chips"} 267 + s := &cachedStaplesService{ 268 + cache: IO(cacheStore), 269 + grader: grader, 270 + provider: &stubRoutingStaplesProvider{ 271 + ingredients: []ai.InputIngredient{chips, steak}, 272 + }, 273 + } 274 + 275 + params := &generatorParams{ 276 + Location: &locations.Location{ID: "70100023", Name: "Test Store"}, 277 + Date: time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC), 278 + } 279 + 280 + got, err := s.FetchStaples(t.Context(), params) 281 + if err != nil { 282 + t.Fatalf("FetchStaples returned error: %v", err) 283 + } 284 + if len(got) != 2 { 285 + t.Fatalf("unexpected graded staples length: %+v", got) 286 + } 287 + slices.SortFunc(got, func(a, b ai.InputIngredient) int { 288 + return strings.Compare(a.Description, b.Description) 289 + }) 290 + if got[0].Description != "Potato Chips" || got[0].Grade == nil || got[0].Grade.Score != 10 { 291 + t.Fatalf("unexpected graded staple entry: %+v", got[0]) 292 + } 293 + if got[1].Description != "Ribeye Steak" || got[1].Grade == nil || got[1].Grade.Score != 10 { 294 + t.Fatalf("unexpected graded staple entry: %+v", got[1]) 295 + } 296 + if len(grader.ingredients) != 2 { 297 + t.Fatalf("expected grader to see raw cached ingredients, got %d", len(grader.ingredients)) 298 + } 299 + 300 + cached, err := IO(cacheStore).IngredientsFromCache(t.Context(), params.LocationHash()) 301 + if err != nil { 302 + t.Fatalf("IngredientsFromCache returned error: %v", err) 303 + } 304 + if len(cached) != 2 { 305 + t.Fatalf("expected cached ingredients, got %+v", cached) 306 + } 307 + slices.SortFunc(cached, func(a, b ai.InputIngredient) int { 308 + return strings.Compare(a.Description, b.Description) 309 + }) 310 + if cached[0].Description != "Potato Chips" || cached[0].Grade == nil || cached[0].Grade.Score != 10 { 311 + t.Fatalf("expected graded chips in cache, got %+v", cached[0]) 312 + } 313 + if cached[1].Description != "Ribeye Steak" || cached[1].Grade == nil || cached[1].Grade.Score != 10 { 314 + t.Fatalf("expected graded steak in cache, got %+v", cached[1]) 315 + } 316 + }