ai cooking
0
fork

Configure Feed

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

Add CLI acceptance check for produce availability by location (#248)

* Add produce availability acceptance command

* a little closer

* something i guess

* okay we've got a score.

* improved score

* okay a little futher but still not great

* codex iterations

* docs update

* consolidate generation

* codex code review

* back off some silly queries

* got something

* simplify down dramatically

* not overfit

* fix test

* dedupe

* a few more things

* some pr comments

* okay restore get ingredients goodness

* what is this lettuce nonsense

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
001af599 e29b7a2b

+893 -24
+2 -1
AGENTS.md
··· 33 33 - Place tests alongside code in `*_test.go`; prefer table-driven cases and explicit fixtures over implicit globals. 34 34 - Use `go test ./... -run TestName` for targeted debugging; keep deterministic by avoiding network calls and using fakes where possible. 35 35 - When touching recipe generation or Kroger client code, add assertions that cover API shape changes and template output (see existing tests in `internal/recipes` and `internal/html`). 36 - 36 + - When changing the generator produce filter list (`internal/recipes/params.go` `Produce()`), also run `go run ./cmd/producecheck -location 70500874` and see if score changes. Will need secrets in .envtest file 37 + 37 38 ## Commit & Pull Request Guidelines 38 39 - Reference an issue/PR number when applicable. Say why something was done rather than just what was done. 39 40 - In PRs, include: what changed, why, how to verify (commands run), and any config/env impacts. Add screenshots for UI changes using `internal/templates`.
+6 -5
cmd/ingredients/main.go
··· 8 8 "flag" 9 9 "fmt" 10 10 "log" 11 + "strings" 11 12 ) 12 13 13 14 func main() { ··· 46 47 } 47 48 48 49 for _, i := range ings { 49 - fmt.Printf("%s:$%s original ($%s)\n", toString(i.Description), toFloat(i.PriceSale), toFloat(i.PriceRegular)) 50 + fmt.Printf("%s - %s:(%s))\n", toString(i.Brand), toString(i.Description), strings.Join(toSlice(i.Categories), ",")) 50 51 } 51 52 52 53 } 53 54 54 - func toFloat(f *float32) string { 55 - if f == nil { 56 - return "0" 55 + func toSlice(s *[]string) []string { 56 + if s == nil { 57 + return nil 57 58 } 58 - return fmt.Sprintf("%.2f", *f) 59 + return *s 59 60 } 60 61 61 62 func toString(s *string) string {
+387
cmd/producecheck/main.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/config" 6 + "careme/internal/kroger" 7 + "careme/internal/recipes" 8 + "context" 9 + "flag" 10 + "fmt" 11 + "log" 12 + "slices" 13 + "strings" 14 + "sync" 15 + "unicode" 16 + 17 + "github.com/samber/lo" 18 + "golang.org/x/text/unicode/norm" 19 + ) 20 + 21 + func main() { 22 + var locationID string 23 + var produceCSV string 24 + 25 + //local to bellevue Fred Meyer 70100023, factoria 70500822 26 + flag.StringVar(&locationID, "location", "70500874", "Kroger location ID to validate") 27 + flag.StringVar(&locationID, "l", "70500874", "Kroger location ID to validate (short)") 28 + flag.StringVar(&produceCSV, "produce", strings.Join(all, ","), "Comma-separated produce list to check") 29 + flag.Parse() 30 + 31 + if strings.TrimSpace(locationID) == "" { 32 + log.Fatalf("missing required -location flag") 33 + } 34 + 35 + produce := parseProduceList(produceCSV) 36 + if len(produce) == 0 { 37 + log.Fatalf("no produce terms provided") 38 + } 39 + 40 + ctx := context.Background() 41 + cfg, err := config.Load() 42 + if err != nil { 43 + log.Fatalf("failed to load config: %v", err) 44 + } 45 + 46 + cacheStore, err := cache.MakeCache() 47 + if err != nil { 48 + log.Fatalf("failed to create cache: %v", err) 49 + } 50 + 51 + generator, err := recipes.NewGenerator(cfg, cacheStore) 52 + if err != nil { 53 + log.Fatalf("failed to create recipe generator: %v", err) 54 + } 55 + g, ok := generator.(*recipes.Generator) 56 + if !ok { 57 + log.Fatalf("failed to cast generator to *recipes.Generator") 58 + } 59 + 60 + missing, results, err := checkProduceAvailability(ctx, g, locationID, produce) 61 + if err != nil { 62 + log.Fatalf("availability check failed: %v", err) 63 + } 64 + 65 + if len(missing) > 0 { 66 + fmt.Printf("missing produce for location %s: %s\n", locationID, strings.Join(missing, ", ")) 67 + } 68 + 69 + fmt.Printf("Produce score %f: %d/%d with %d ingredients\n", float64(len(produce)-len(missing))/float64(len(produce)), len(produce)-len(missing), len(produce), results) 70 + } 71 + 72 + func parseProduceList(csv string) []string { 73 + parts := strings.Split(csv, ",") 74 + produce := make([]string, 0, len(parts)) 75 + seen := make(map[string]struct{}, len(parts)) 76 + for _, part := range parts { 77 + term := normalizeTerm(part) 78 + if term == "" { 79 + continue 80 + } 81 + if _, ok := seen[term]; ok { 82 + continue 83 + } 84 + seen[term] = struct{}{} 85 + produce = append(produce, term) 86 + } 87 + return produce 88 + } 89 + 90 + type produceFilterStats struct { 91 + FilterTerm string 92 + IngredientMatches int 93 + ProduceTermsMatched int 94 + ProduceMatches int 95 + UniqueOnlyMatches int 96 + matchedDescriptions []string 97 + } 98 + 99 + func checkProduceAvailability(ctx context.Context, g *recipes.Generator, locationID string, produce []string) ([]string, int, error) { 100 + 101 + filters := recipes.Produce() 102 + type filterResult struct { 103 + filter string 104 + ingredients []kroger.Ingredient 105 + err error 106 + } 107 + //todo check total number of queries. 108 + results := make([]filterResult, len(filters)) 109 + var wg sync.WaitGroup 110 + wg.Add(len(filters)) 111 + for i, filter := range filters { 112 + i, filter := i, filter 113 + go func() { 114 + defer wg.Done() 115 + filterIngredients, err := g.GetIngredients(ctx, locationID, filter, 0) 116 + results[i] = filterResult{ 117 + filter: filter.Term, 118 + ingredients: filterIngredients, 119 + err: err, 120 + } 121 + }() 122 + } 123 + wg.Wait() 124 + ingredients := make([]kroger.Ingredient, 0, 300) 125 + stats := make([]produceFilterStats, 0, len(filters)) 126 + for _, result := range results { 127 + if result.err != nil { 128 + log.Printf("warning: failed to get ingredients for filter %q at location %s: %v", result.filter, locationID, result.err) 129 + continue 130 + } 131 + matchedTerms, matchedProducts, matchedDescriptions := summarizeFilterMatchesDetailed(produce, result.ingredients) 132 + stats = append(stats, produceFilterStats{ 133 + FilterTerm: result.filter, 134 + IngredientMatches: len(result.ingredients), 135 + ProduceTermsMatched: matchedTerms, 136 + ProduceMatches: matchedProducts, 137 + matchedDescriptions: matchedDescriptions, 138 + }) 139 + ingredients = append(ingredients, result.ingredients...) 140 + } 141 + ingredients = lo.UniqBy(ingredients, func(i kroger.Ingredient) string { return toString(i.Description) }) 142 + 143 + annotateUniqueOnlyMatches(stats) 144 + printProduceFilterSummary(stats, len(produce)) 145 + 146 + return evaluateProduceAvailability(produce, ingredients), len(ingredients), nil 147 + } 148 + 149 + func toString(s *string) string { 150 + if s == nil { 151 + return "" 152 + } 153 + return *s 154 + } 155 + 156 + func evaluateProduceAvailability(produce []string, ingredients []kroger.Ingredient) []string { 157 + missing := make([]string, 0) 158 + for _, term := range produce { 159 + matches := hasProduce(ingredients, term) 160 + if len(matches) > 0 { 161 + fmt.Printf("✅ %s -> %d matches\n", term, len(matches)) 162 + continue 163 + } 164 + fmt.Printf("❌ %s -> no matching products found\n", term) 165 + missing = append(missing, term) 166 + } 167 + 168 + slices.Sort(missing) 169 + return missing 170 + } 171 + 172 + func summarizeFilterMatches(produce []string, ingredients []kroger.Ingredient) (int, int) { 173 + matchedTerms, matchedProducts, _ := summarizeFilterMatchesDetailed(produce, ingredients) 174 + return matchedTerms, matchedProducts 175 + } 176 + 177 + func summarizeFilterMatchesDetailed(produce []string, ingredients []kroger.Ingredient) (int, int, []string) { 178 + matchedTerms := 0 179 + matchedProducts := 0 180 + descriptions := make(map[string]struct{}) 181 + for _, term := range produce { 182 + matches := hasProduce(ingredients, term) 183 + if len(matches) == 0 { 184 + continue 185 + } 186 + matchedTerms++ 187 + matchedProducts += len(matches) 188 + for _, description := range matches { 189 + descriptions[description] = struct{}{} 190 + } 191 + } 192 + matchedDescriptions := make([]string, 0, len(descriptions)) 193 + for description := range descriptions { 194 + matchedDescriptions = append(matchedDescriptions, description) 195 + } 196 + slices.Sort(matchedDescriptions) 197 + return matchedTerms, matchedProducts, matchedDescriptions 198 + } 199 + 200 + // see what filters return results NOT seen elsewhere then update UniqueOnlyMatches 201 + func annotateUniqueOnlyMatches(stats []produceFilterStats) { 202 + descriptionCount := make(map[string]int) 203 + for _, stat := range stats { 204 + for _, description := range stat.matchedDescriptions { 205 + descriptionCount[description]++ 206 + } 207 + } 208 + 209 + for i := range stats { 210 + uniqueOnly := 0 211 + for _, description := range stats[i].matchedDescriptions { 212 + if descriptionCount[description] == 1 { 213 + uniqueOnly++ 214 + } 215 + } 216 + stats[i].UniqueOnlyMatches = uniqueOnly 217 + } 218 + } 219 + 220 + func printProduceFilterSummary(stats []produceFilterStats, totalProduceTerms int) { 221 + if len(stats) == 0 { 222 + fmt.Println("produce filter summary: no filters returned results") 223 + return 224 + } 225 + 226 + fmt.Println() 227 + fmt.Println("produce filter summary:") 228 + for _, stat := range stats { 229 + fmt.Printf("- %s -> %d ingredients, %d/%d produce terms, %d matches, %d unique-only products\n", 230 + stat.FilterTerm, 231 + stat.IngredientMatches, 232 + stat.ProduceTermsMatched, 233 + totalProduceTerms, 234 + stat.ProduceMatches, 235 + stat.UniqueOnlyMatches, 236 + ) 237 + } 238 + fmt.Println() 239 + } 240 + 241 + func hasProduce(ingredients []kroger.Ingredient, term string) []string { 242 + needleTokens := tokens(normalizeTerm(term)) 243 + if len(needleTokens) == 0 { 244 + return nil 245 + } 246 + seen := make(map[string]struct{}) 247 + matches := make([]string, 0) 248 + for _, ingredient := range ingredients { 249 + if ingredient.Description == nil { 250 + continue 251 + } 252 + description := strings.TrimSpace(*ingredient.Description) 253 + if description == "" { 254 + continue 255 + } 256 + haystackTokens := tokens(normalizeTerm(description)) 257 + if !containsAllTokens(haystackTokens, needleTokens) { 258 + continue 259 + } 260 + if _, ok := seen[description]; ok { 261 + continue 262 + } 263 + seen[description] = struct{}{} 264 + matches = append(matches, description) 265 + } 266 + 267 + slices.Sort(matches) 268 + return matches 269 + } 270 + 271 + func normalizeTerm(s string) string { 272 + s = strings.TrimSpace(strings.ToLower(s)) 273 + s = removeParenthetical(s) 274 + s = stripDiacritics(s) 275 + var b strings.Builder 276 + b.Grow(len(s)) 277 + for _, r := range s { 278 + if unicode.IsLetter(r) || unicode.IsNumber(r) { 279 + b.WriteRune(r) 280 + continue 281 + } 282 + b.WriteRune(' ') 283 + } 284 + fields := strings.Fields(b.String()) 285 + if len(fields) == 0 { 286 + return "" 287 + } 288 + normalized := make([]string, 0, len(fields)) 289 + for _, field := range fields { 290 + token := normalizeToken(field) 291 + if token == "" { 292 + continue 293 + } 294 + normalized = append(normalized, token) 295 + } 296 + return strings.Join(normalized, " ") 297 + } 298 + 299 + func tokens(s string) []string { 300 + if s == "" { 301 + return nil 302 + } 303 + return strings.Fields(s) 304 + } 305 + 306 + func containsAllTokens(haystack []string, needle []string) bool { 307 + if len(needle) == 0 { 308 + return false 309 + } 310 + set := make(map[string]struct{}, len(haystack)) 311 + for _, token := range haystack { 312 + set[token] = struct{}{} 313 + } 314 + for _, token := range needle { 315 + if _, ok := set[token]; !ok { 316 + return false 317 + } 318 + } 319 + return true 320 + } 321 + 322 + func removeParenthetical(s string) string { 323 + var b strings.Builder 324 + b.Grow(len(s)) 325 + depth := 0 326 + for _, r := range s { 327 + switch r { 328 + case '(': 329 + depth++ 330 + case ')': 331 + if depth > 0 { 332 + depth-- 333 + } 334 + default: 335 + if depth == 0 { 336 + b.WriteRune(r) 337 + } 338 + } 339 + } 340 + return b.String() 341 + } 342 + 343 + func stripDiacritics(s string) string { 344 + decomposed := norm.NFD.String(s) 345 + var b strings.Builder 346 + b.Grow(len(decomposed)) 347 + for _, r := range decomposed { 348 + if unicode.Is(unicode.Mn, r) { 349 + continue 350 + } 351 + b.WriteRune(r) 352 + } 353 + return norm.NFC.String(b.String()) 354 + } 355 + 356 + func normalizeToken(s string) string { 357 + 358 + switch { 359 + case strings.HasSuffix(s, "ies") && len(s) > 3: 360 + s = s[:len(s)-3] + "y" 361 + case strings.HasSuffix(s, "oes") && len(s) > 3: 362 + s = s[:len(s)-2] 363 + case strings.HasSuffix(s, "ches") || strings.HasSuffix(s, "shes") || strings.HasSuffix(s, "xes") || strings.HasSuffix(s, "zes") || strings.HasSuffix(s, "ses"): 364 + if len(s) > 4 { 365 + s = s[:len(s)-2] 366 + } 367 + case strings.HasSuffix(s, "s") && !strings.HasSuffix(s, "ss") && len(s) > 2: 368 + s = s[:len(s)-1] 369 + } 370 + 371 + switch s { 372 + case "kiwifruit": 373 + s = "kiwi" 374 + case "asparagus": 375 + return s 376 + case "portobello": 377 + s = "portabella" 378 + case "chile": 379 + s = "chili" 380 + } 381 + 382 + switch s { 383 + case "brussel": 384 + return "brussels" 385 + } 386 + return s 387 + }
+141
cmd/producecheck/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/kroger" 5 + "reflect" 6 + "testing" 7 + ) 8 + 9 + func TestParseProduceList(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + csv string 13 + want []string 14 + }{ 15 + { 16 + name: "dedupes and normalizes", 17 + csv: " carrots,Carrots, brussel sprouts , kale ", 18 + want: []string{"carrot", "brussels sprout", "kale"}, 19 + }, 20 + { 21 + name: "drops blanks", 22 + csv: " , , apples", 23 + want: []string{"apple"}, 24 + }, 25 + } 26 + 27 + for _, tc := range tests { 28 + t.Run(tc.name, func(t *testing.T) { 29 + got := parseProduceList(tc.csv) 30 + if !reflect.DeepEqual(got, tc.want) { 31 + t.Fatalf("parseProduceList() = %#v, want %#v", got, tc.want) 32 + } 33 + }) 34 + } 35 + } 36 + 37 + func TestNormalizeTerm(t *testing.T) { 38 + got := normalizeTerm(" Brussel Sprouts ") 39 + want := "brussels sprout" 40 + if got != want { 41 + t.Fatalf("normalizeTerm() = %q, want %q", got, want) 42 + } 43 + } 44 + 45 + func TestNormalizeTerm_RemovesParentheticalAndDiacritics(t *testing.T) { 46 + got := normalizeTerm(" Green Onions (Scallions), Jalapeño Peppers ") 47 + want := "green onion jalapeno pepper" 48 + if got != want { 49 + t.Fatalf("normalizeTerm() = %q, want %q", got, want) 50 + } 51 + } 52 + 53 + func TestHasProduce_UsesTokenMatching(t *testing.T) { 54 + descriptions := []*string{ 55 + strPtr("Fresh Seedless Mini Cucumbers"), 56 + strPtr("Fresh Jalapeno Peppers"), 57 + strPtr("Simple Truth Organic® Whole Baby Bella Mushrooms"), 58 + strPtr("Simple Truth Organic® Kiwifruit"), 59 + } 60 + ingredients := make([]kroger.Ingredient, 0, len(descriptions)) 61 + for _, d := range descriptions { 62 + ingredients = append(ingredients, kroger.Ingredient{Description: d}) 63 + } 64 + 65 + tests := []struct { 66 + term string 67 + want int 68 + }{ 69 + {term: "seedless cucumbers", want: 1}, 70 + {term: "jalapeño peppers", want: 1}, 71 + {term: "baby bella (cremini) mushrooms", want: 1}, 72 + {term: "kiwi", want: 1}, 73 + } 74 + for _, tc := range tests { 75 + got := hasProduce(ingredients, tc.term) 76 + if len(got) != tc.want { 77 + t.Fatalf("hasProduce(%q) = %d matches, want %d", tc.term, len(got), tc.want) 78 + } 79 + } 80 + } 81 + 82 + func TestSummarizeFilterMatches(t *testing.T) { 83 + descriptions := []*string{ 84 + strPtr("Fresh Seedless Mini Cucumbers"), 85 + strPtr("Fresh Mini Cucumbers"), 86 + strPtr("Fresh Jalapeno Peppers"), 87 + strPtr("Simple Truth Organic® Kiwifruit"), 88 + } 89 + ingredients := make([]kroger.Ingredient, 0, len(descriptions)) 90 + for _, d := range descriptions { 91 + ingredients = append(ingredients, kroger.Ingredient{Description: d}) 92 + } 93 + 94 + produce := []string{ 95 + "seedless cucumbers", 96 + "jalapeño peppers", 97 + "kiwi", 98 + "dill", 99 + } 100 + 101 + matchedTerms, matchedProducts := summarizeFilterMatches(produce, ingredients) 102 + if matchedTerms != 3 { 103 + t.Fatalf("summarizeFilterMatches() matchedTerms = %d, want %d", matchedTerms, 3) 104 + } 105 + if matchedProducts != 3 { 106 + t.Fatalf("summarizeFilterMatches() matchedProducts = %d, want %d", matchedProducts, 3) 107 + } 108 + } 109 + 110 + func TestAnnotateUniqueOnlyMatches(t *testing.T) { 111 + stats := []produceFilterStats{ 112 + { 113 + FilterTerm: "fresh produce", 114 + matchedDescriptions: []string{"A", "B", "C"}, 115 + }, 116 + { 117 + FilterTerm: "mushrooms produce", 118 + matchedDescriptions: []string{"B", "D"}, 119 + }, 120 + { 121 + FilterTerm: "fresh peppers", 122 + matchedDescriptions: []string{"E"}, 123 + }, 124 + } 125 + 126 + annotateUniqueOnlyMatches(stats) 127 + 128 + if stats[0].UniqueOnlyMatches != 2 { 129 + t.Fatalf("stats[0].UniqueOnlyMatches = %d, want %d", stats[0].UniqueOnlyMatches, 2) 130 + } 131 + if stats[1].UniqueOnlyMatches != 1 { 132 + t.Fatalf("stats[1].UniqueOnlyMatches = %d, want %d", stats[1].UniqueOnlyMatches, 1) 133 + } 134 + if stats[2].UniqueOnlyMatches != 1 { 135 + t.Fatalf("stats[2].UniqueOnlyMatches = %d, want %d", stats[2].UniqueOnlyMatches, 1) 136 + } 137 + } 138 + 139 + func strPtr(s string) *string { 140 + return &s 141 + }
+138
cmd/producecheck/testdata.go
··· 1 + package main 2 + 3 + var fruit = []string{ 4 + "bananas", 5 + "apples", 6 + "pears", 7 + "oranges", 8 + "cherries", 9 + "grapes", 10 + "strawberries", 11 + "blueberries", 12 + "raspberries", 13 + "blackberries", 14 + "watermelon", 15 + "cantaloupe", 16 + "honeydew melon", 17 + "kiwi", 18 + "pineapple", 19 + "mangoes", 20 + } 21 + 22 + var tubers = []string{ 23 + "onions", 24 + "potatoes", 25 + } 26 + 27 + var vegetables = []string{ 28 + // Leafy greens & lettuces 29 + "Romaine lettuce", 30 + "Green leaf lettuce", 31 + "Red leaf lettuce", 32 + "Iceberg lettuce", 33 + "Butterhead lettuce", 34 + "Little gem lettuce", 35 + "Spring mix", 36 + "Baby spring mix", 37 + "Arugula", 38 + "Baby arugula", 39 + "Spinach", 40 + "Baby spinach", 41 + "Kale", 42 + "Curly kale", 43 + "Lacinato kale", 44 + "Rainbow chard", 45 + "Bok choy", 46 + "Baby bok choy", 47 + "Napa cabbage", 48 + "Green cabbage", 49 + "Red cabbage", 50 + "Radicchio", 51 + 52 + // Brassicas 53 + "Broccoli", 54 + "Broccolini", 55 + "Cauliflower", 56 + "Brussels sprouts", 57 + 58 + // Roots & tubers 59 + "Carrots", 60 + "Baby carrots", 61 + "Rainbow carrots", 62 + "Beets", 63 + "Golden beets", 64 + "Turnips", 65 + "Rutabaga", 66 + "Parsnips", 67 + "Daikon radish", 68 + "Radishes", 69 + "Horseradish root", 70 + "Celery root (celeriac)", 71 + "Jicama", 72 + "Yuca (cassava)", 73 + 74 + // Alliums 75 + "Green onions (scallions)", 76 + "Leeks", 77 + "Garlic", 78 + 79 + // Stalks & stems 80 + "Celery", 81 + "Asparagus", 82 + "Lemongrass", 83 + 84 + // Fruiting vegetables 85 + "Green bell peppers", 86 + "Red bell peppers", 87 + "Yellow bell peppers", 88 + "Orange bell peppers", 89 + "Mini sweet peppers", 90 + "Poblano peppers", 91 + "Jalapeño peppers", 92 + "Serrano peppers", 93 + "Anaheim peppers", 94 + "Habanero peppers", 95 + "Red chili peppers", 96 + "Green chili peppers", 97 + "Tomatillos", 98 + "Zucchini", 99 + "Yellow squash", 100 + "Cucumber", 101 + "Mini cucumbers", 102 + "Seedless cucumbers", 103 + "Eggplant", 104 + "Green beans", 105 + "Sweet corn", 106 + 107 + // Mushrooms 108 + "White mushrooms", 109 + "Baby bella (cremini) mushrooms", 110 + "Portobello mushrooms", 111 + "Shiitake mushrooms", 112 + "Oyster mushrooms", 113 + "King trumpet mushrooms", 114 + "Sliced mushroom blend", 115 + 116 + // Herbs 117 + "Parsley", 118 + "Italian parsley", 119 + "Cilantro", 120 + "Basil", 121 + "Thyme", 122 + "Sage", 123 + "Rosemary", 124 + "Tarragon", 125 + "Dill", 126 + "Chives", 127 + 128 + // Sprouts & microgreens 129 + "Alfalfa sprouts", 130 + "Broccoli sprouts", 131 + "Mixed sprouts", 132 + 133 + // Other 134 + "Aloe vera leaf", 135 + "Bean sprouts", 136 + } 137 + 138 + var all = append(append(fruit, tubers...), vegetables...)
+186
docs/produce-score-tradeoffs.md
··· 1 + # Produce Score Tradeoffs 2 + 3 + Date: 2026-02-22 4 + Location: `70500874` (default from `cmd/producecheck`) 5 + Command: `go run ./cmd/producecheck` with `.envtest` credentials 6 + 7 + ## Result Summary 8 + 9 + - Baseline before changes: `62/107` (`0.579439`) with `332` ingredients. 10 + - Best observed after iteration: `93/107` (`0.869159`) with `672` ingredients. 11 + - Observed range on repeated runs with the final config: `89-93` / `107` (Kroger fuzzy ordering varies). 12 + 13 + ## What Changed 14 + 15 + ### 1) Produce filter strategy (`internal/recipes/params.go`) 16 + 17 + I expanded `Produce()` from a small set of broad terms to a mixed set: 18 + 19 + - Broad recall terms: `produce`, `fresh produce` 20 + - Targeted recovery terms for persistent misses: 21 + - peppers/chiles: `bell peppers`, `red chili peppers`, `green chili peppers`, `mini sweet peppers` 22 + - mushrooms: `mushrooms produce`, `king trumpet mushrooms` 23 + - sprouts: `alfalfa sprouts`, `broccoli sprouts`, `bean sprouts` 24 + - cucumbers: `cucumber produce`, `seedless cucumbers` 25 + - other gaps: `little gem produce`, `chives`, `napa cabbage`, `eggplant`, `parsnip produce` 26 + 27 + All produce filters now consistently use `Brands: []string{"*"}` to avoid accidental exclusion of branded produce. 28 + 29 + ### 2) Produce matching robustness (`cmd/producecheck/main.go`) 30 + 31 + I improved term normalization and matching to reduce obvious false negatives: 32 + 33 + - strips diacritics (`jalapeño` -> `jalapeno`) 34 + - removes parenthetical aliases (`green onions (scallions)` -> `green onions`) 35 + - normalizes token morphology (basic singularization/plural handling) 36 + - maps known token variants (`portobello` -> `portabella`, `kiwifruit` -> `kiwi`, `chile` -> `chili`) 37 + - uses token-set matching instead of raw substring matching 38 + - allows optional trailing `lettuce` token to match `little gem` descriptions 39 + 40 + ### 3) "Fresh" Seems to get more than what produce gets with out losing much. 41 + 42 + too many false positives? 43 + 44 + • Compared as unique non-empty lines (normalized line endings), here’s the difference: 45 + 46 + freshproduce.txt but not produce.txt (49 lines): 47 + 48 + - Butternut Squash:(Produce,Produce)) 49 + - Chayote Squash:(International,Produce,Produce)) 50 + - Fresh Kroger® D'Anjou Pears - 2 Pound Bag:(Produce,Produce)) 51 + - Fresh Tomatillo:(Produce,International,Produce)) 52 + - Golden Acorn Squash:(Produce,Produce)) 53 + - Kroger® Fresh French Green Beans Bag:(Produce,Produce,Produce)) 54 + - Mini Seedless Whole Watermelon:(Produce,Produce)) 55 + - Organic Parsley:(Produce,Produce)) 56 + - Spaghetti Squash:(Produce,Produce)) 57 + Driscoll's - Driscoll’s® Limited Edition Sweetest Batchâ„¢ Fresh Blueberries:(Produce,Produce)) 58 + Fresh Apples - Organic Granny Smith Apple – Each:(Produce,Produce)) 59 + Fresh Apples - Small Gala Apple – Each:(Produce,Produce)) 60 + Fresh Berries - Driscoll's® Only the Finest Berries™ Raspberries:(Produce,Produce)) 61 + Fresh Berries - Driscoll’s Rainbow Pack Fresh Blackberries Blueberries & Raspberries:(Produce,Produce)) 62 + Fresh Berries - Driscoll’s Sweetest Batch Fresh Blackberries:(Produce,Produce)) 63 + Fresh Berries - Fresh Red Raspberries - 12 OZ Clamshell:(Produce,Produce)) 64 + Fresh Corn - Fresh Sweet Corn on the Cob-Each:(Produce,Produce)) 65 + Fresh Melons - Honeydew Melon:(Produce,Produce)) 66 + Fresh Melons - Seeded Whole Watermelon:(Produce,Produce)) 67 + Fresh Melons - Seedless Whole Watermelon:(Produce,Produce)) 68 + Fresh Stone fruit - Fresh California Yellow Peach – Each:(Produce,Produce)) 69 + Fresh Stone fruit - Fresh White Nectarine:(Produce,Produce,International)) 70 + Fresh Stone fruit - Fresh White Peach - Each:(Produce,Produce)) 71 + Fresh Stone fruit - Fresh Yellow Nectarine:(Produce,Produce,International)) 72 + Fresh Stone fruit - Kroger® Fresh Peaches in 2 LB Bag:(Produce,Produce)) 73 + Fresh Stone fruit - Kroger® Fresh Plums in 2 LB Bag:(Produce,Produce)) 74 + Fresh Tomatoes - Fresh Heirloom Tomato:(Produce,Produce)) 75 + Fresh Tomatoes - Fresh Large Hothouse Grown Premium Red Tomato:(Produce,Produce)) 76 + Fresh Tomatoes - Fresh Large Red Slicing Tomato:(Produce,Produce)) 77 + Fresh Tomatoes - Fresh Red Tomatoes:(Produce,Produce)) 78 + Frieda's - Frieda's Opo Squash:(Produce,Produce)) 79 + Kroger - Fresh Kroger® Bosc Pears - 2 Pound Bag:(Produce,Produce)) 80 + Kroger - Kroger® Brussels Sprouts BIG DEAL!:(Produce,Produce)) 81 + Kroger - Kroger® Brussels Sprouts Halves:(Produce,Produce)) 82 + Kroger - Kroger® Fresh Bartlett Pears – 2 Pound Bag:(Produce,Produce)) 83 + Kroger - Kroger® Fresh Navel Oranges Bag:(Produce,Produce)) 84 + Nature Sweet - NatureSweet Cherubs Fresh Heavenly Salad Grape Tomatoes, 10 oz:(Produce,Produce)) 85 + Nature Sweet - NatureSweet Cherubs® Fresh Grape Tomatoes:(Produce,Produce)) 86 + Nature Sweet - NatureSweet Constellation® Medley Fresh Snacking Tomatoes:(Produce,Produce)) 87 + Nature Sweet - NatureSweet Glorys Cherry Tomatoes, 10 oz:(Produce,Produce)) 88 + Private Selection - Private Selection® Fresh Colossal Blueberries:(Produce,Produce)) 89 + Private Selection - Private Selection® Fresh Petite Cherry Snacking Tomatoes:(Produce,Produce)) 90 + Private Selection - Private Selection® Petite Grape Snacking Tomatoes:(Produce,Produce)) 91 + Private Selection - Private Selection® Sweet Karoline Fresh Blackberries:(Produce,Produce)) 92 + Private Selection - Private Selection® Campari Tomatoes:(Produce,Produce)) 93 + Private Selection - Private Selection® Ruby Rowsâ„¢ Fresh Cherry Tomatoes on the Vine:(Produce,Produce)) 94 + Private Selection - Private Selection™ Fresh Petite Cherry Snacking Tomatoes:(Produce,Produce)) 95 + Simple Truth Organic - Simple Truth Organic® Fresh Grapefruit Bag:(Produce,Produce)) 96 + Simple Truth Organic - Simple Truth Organic® Fresh Cranberries:(Produce,Produce)) 97 + 98 + produce.txt but not freshproduce.txt (48 lines): 99 + 100 + - Spinach:(Produce,Produce)) 101 + Adwood Manufacturing - Adwood Manufacturing Wooden Produce Crate:(Cleaning Products)) 102 + Bluey - Bluey Medley with Peeled Sweet Apple Slices Cheese and Pretzels:(Produce)) 103 + Bob's Red Mill - Bob's Red Mill® Organic Whole Grain Tri-Color Quinoa:(Pasta, Sauces, Grain,Natural & Organic,Natural & Organic)) 104 + Core Kitchen - Core™ Kitchen Produce Crisper Bin with Draining Board:(Cleaning Products)) 105 + Crunch Pak - Crunch Pak® Grab 'N Go Organic Sweet Apple Slices:(Natural & Organic,Produce,Produce)) 106 + Crunch Pak - Crunch Pak® Grab'N Go Sweet Apple Slice Snack Pack:(Produce)) 107 + Crunch Pak - Crunch Pak® Medley With Reese's Minis and Apples Snack Tray:(Produce)) 108 + Crunch Pak - Crunch Pak® Minions Medley With Apple Slices Turkey Sausage Bites & Pretzels:(Produce,Meat & Seafood)) 109 + Crunch Pak - Crunch Pak® Nickelodeon Paw Patrol™ Sliced Sweet Apples Cheese Grapes and Cookies Snack Tray:(Produce,Meat & Seafood)) 110 + Crunch Pak - Crunch Pak® Nickelodeon™ Paw Patrol™ Peeled Sweet Apples Fruit Snacks Cheese and Cookies Snack Tray:(Produce)) 111 + Crunch Pak - Crunch Pak® Peeled Apple Slices:(Produce)) 112 + Crunch Pak - Crunch Pak® Disney Foodle® Sweet Apples Cheese & Crackers Tray:(Produce,Meat & Seafood)) 113 + Del Monte - Del Monte No Sugar Added Citrus Salad in Sweetened Water:(International,Produce)) 114 + Del Monte - Del Monte® No Sugar Added Red Grapefruit Jar:(International,Produce)) 115 + Del Monte - Del Monte® Red Grapefruit in Extra Light Syrup:(International,Produce)) 116 + GoodCook® - GoodCook® Touch® Produce Chopper:(Kitchen)) 117 + Gotham Greens - Gotham Greens® Gourmet Spring Mix Lettuce:(Produce)) 118 + Gourmet Garden - Gourmet Garden Lightly Dried Chopped Parsley:(Natural & Organic,Baking Goods)) 119 + Kroger - Kroger® Carrots Celery And Broccoli Snack Tray With Ranch Dip:(Produce,Snacks)) 120 + Kroger - Kroger® No Sugar Added Red Grapefruit Cup:(Snacks,Produce)) 121 + Kroger - Kroger® Sliced Apples Chocolatey Caramels and Pretzels Snack Tray:(Meat & Seafood,Produce)) 122 + Kroger - Kroger® Sweet Apples Cheddar Cheese and Pretzels Snack Tray:(Produce)) 123 + Kroger - Kroger® Sweet Apples and Caramel Snack Tray:(Produce,Snacks)) 124 + Kroger - Kroger® Tart Apples and Caramel Snack Tray BIG DEAL!:(Produce)) 125 + Kroger - Kroger® Veggie Chips & Buffalo Dip:(Produce,Produce)) 126 + Naturipe - Naturipe Snacks® Berry Buddies™ Berries & Pancakes:(Produce)) 127 + Naturipe - Naturipe Snacks® Bliss Bento® Berry Lemony Snack Pack:(Produce)) 128 + Oh Snap! Pickling Co. - OH SNAP!® Dilly Bites® Pickle Pouches:(Meat & Seafood,Snacks,Canned & Packaged,Produce)) 129 + Oh Snap! Pickling Co. - Oh Snap!® Pickling Co. Hottie Bites® Pickle Cuts Snack Pack:(Canned & Packaged,Produce,Meat & Seafood,Snacks)) 130 + Oh Snap! Pickling Co. - Oh Snap!® Sassy Bites™ Sweet N' Spicy Pickle Snack Pack:(Canned & Packaged,Produce,Meat & Seafood,Snacks)) 131 + Seoul - Seoul Original Kimchi:(Produce,International)) 132 + Simple Truth - Simple Truth Organic® Broccoli Florets:(Natural & Organic)) 133 + Simple Truth Organic - Simple Truth Organic® Avocado Ranch Chopped Salad Kit:(Produce,Produce,Natural & Organic,International)) 134 + Simple Truth Organic - Simple Truth Organic® Baby Red Butter & Arugula Salad Mix:(Natural & Organic)) 135 + Simple Truth Organic - Simple Truth Organic® Classic Caesar Salad Kit:(Natural & Organic,Natural & Organic)) 136 + Simple Truth Organic - Simple Truth Organic® Ginger Container:(Natural & Organic,Baking Goods)) 137 + Simple Truth Organic - Simple Truth Organic® Gourmet Fingerling Potatoes:(Natural & Organic)) 138 + Simple Truth Organic - Simple Truth Organic® Little Gem Butter Crunch Salad Mix:(Natural & Organic,Produce,Produce)) 139 + Simple Truth Organic - Simple Truth Organic® Little Gem Romaine Butter Crunch Salad Mix:(Natural & Organic)) 140 + Simple Truth Organic - Simple Truth Organic® Mediterranean Style Medley Salad Starter:(Natural & Organic)) 141 + Simple Truth Organic - Simple Truth Organic® Tomato Medley Snacking Tomatoes:(Natural & Organic)) 142 + Simple Truth Organic - Simple Truth Organic® Very Veggie Medley Salad Starter:(Natural & Organic)) 143 + Simple Truth Organic - Simple Truth Organic® Wild Red Arugula:(Produce,Natural & Organic)) 144 + Simple Truth Organic - Simple Truth Organic® Trimmed Green Beans:(Natural & Organic)) 145 + Simple Truth Organic - Simple Truth Organic™ Minced Garlic:(Natural & Organic)) 146 + Sunset - Organic Tomatoes on the Vine in 1lb Bag:(Natural & Organic)) 147 + Ziploc - Ziploc® Brand Gallon Produce Storage Bags, Breathable Bag, Moisture Control, 15 count:(Cleaning Products)) 148 + 149 + ## Key Constraints and Tradeoffs 150 + 151 + 1. Score vs query volume 152 + - Higher score required significantly more query terms. 153 + - Baseline query set was much smaller; final set increases Kroger calls materially. 154 + - Approximate API call growth: from ~10 calls/run to ~25-30 calls/run (depends on pagination and Kroger fuzzy ordering). 155 + 156 + 2. Kroger API limit 157 + - `filter.limit` is hard-capped at `50` (attempting `200` returns `PRODUCT-2013`), so call reduction by page-size increase is not available. 158 + 159 + 3. Stability vs strictness 160 + - Kroger product search ordering is fuzzy and can vary between runs. 161 + - Some targeted terms intermittently return fewer useful rows; simpler direct terms (`bean sprouts`, `napa cabbage`, `king trumpet mushrooms`, etc.) were more stable. 162 + 163 + 4. Precision vs recall 164 + - Broader/tokenized matching improves recall (score) but can include non-produce or adjacent packaged items in some categories. 165 + - This is acceptable for `cmd/producecheck` benchmarking, but should be considered if the same logic is reused elsewhere. 166 + 167 + ## Remaining Misses at Best Run 168 + 169 + At `93/107`, the remaining unmatched terms were: 170 + 171 + - `aloe vera leaf` 172 + - `broccolini` 173 + - `celery root` 174 + - `curly kale` 175 + - `daikon radish` 176 + - `horseradish root` 177 + - `lemongrass` 178 + - `mixed sprout` 179 + - `oyster mushroom` 180 + - `radicchio` 181 + - `red chili pepper` 182 + - `rutabaga` 183 + - `sliced mushroom blend` 184 + - `yuca` 185 + 186 + These appear to be a mix of true availability gaps and query/indexing gaps at this location.
+10 -7
internal/kroger/type.go
··· 3 3 // this is a subset of ProductSearchResponse200Data combining item and product we think will be useful 4 4 // TODO merge with ai.Ingredient 5 5 type Ingredient struct { 6 + //ProductId string `json:"productId,omitempty"` 6 7 AisleNumber *string `json:"number,omitempty"` 7 8 Brand *string `json:"brand,omitempty"` 8 9 //Categories *[]string `json:"categories,omitempty"` 9 - CountryOrigin *string `json:"countryOrigin,omitempty"` 10 - Description *string `json:"description,omitempty"` 11 - Favorite *bool `json:"favorite,omitempty"` //what does this mean? 12 - InventoryStockLevel *string `json:"stockLevel,omitempty"` 13 - PriceSale *float32 `json:"salePrice,omitempty"` 14 - PriceRegular *float32 `json:"regularPrice,omitempty"` 15 - Size *string `json:"size,omitempty"` 10 + CountryOrigin *string `json:"countryOrigin,omitempty"` 11 + Description *string `json:"description,omitempty"` 12 + Favorite *bool `json:"favorite,omitempty"` //what does this mean? 13 + InventoryStockLevel *string `json:"stockLevel,omitempty"` 14 + PriceSale *float32 `json:"salePrice,omitempty"` 15 + PriceRegular *float32 `json:"regularPrice,omitempty"` 16 + Size *string `json:"size,omitempty"` 17 + Categories *[]string `json:"categories,omitempty"` 18 + //Figure out what is in taxonomies 16 19 }
+7 -5
internal/recipes/generator.go
··· 18 18 "sync" 19 19 "time" 20 20 21 + "github.com/samber/lo" 21 22 "github.com/samber/lo/mutable" 22 23 ) 23 24 ··· 150 151 lock.Lock() 151 152 defer lock.Unlock() 152 153 ingredients = append(ingredients, cingredients...) 153 - slog.InfoContext(ctx, "Found ingredients for category", "count", len(cingredients), "category", category.Term, "location", p.Location.ID) 154 + ingredients = lo.UniqBy(ingredients, func(i kroger.Ingredient) string { return toStr(i.Description) }) 155 + slog.InfoContext(ctx, "Found ingredients for category", "count", len(cingredients), "category", category.Term, "location", p.Location.ID, "runningtotal", len(ingredients)) 154 156 }(category) 155 157 } 156 158 ··· 167 169 168 170 // move to krogrer client as everyone will be differnt here? 169 171 func (g *Generator) GetIngredients(ctx context.Context, location string, f filter, skip int) ([]kroger.Ingredient, error) { 170 - limit := 25 172 + limit := 50 171 173 limitStr := strconv.Itoa(limit) 172 174 startStr := strconv.Itoa(skip) 173 175 // brand := "empty" doesn't work have to check for nil 174 176 // fulfillment := "ais" drmatically shortens? 175 177 // wrapped this in a retry and it did nothng 176 - products, err := g.krogerClient.ProductSearchWithResponse(context.TODO(), &kroger.ProductSearchParams{ 178 + products, err := g.krogerClient.ProductSearchWithResponse(ctx, &kroger.ProductSearchParams{ 177 179 FilterLocationId: &location, 178 180 FilterTerm: &f.Term, 179 181 FilterLimit: &limitStr, ··· 234 236 // slog.InfoContext(ctx, "got", "ingredients", len(ingredients), "products", len(*products.JSON200.Data), "term", f.Term, "brands", f.Brands, "location", location, "skip", skip) 235 237 236 238 // recursion is pretty dumb pagination 237 - // 500's seem gone. 238 - if len(*products.JSON200.Data) == limit && skip+limit < 100 { // fence post error 239 + // kroger limites us to 250 240 + if len(*products.JSON200.Data) == limit && skip < 250 { // fence post error 239 241 page, err := g.GetIngredients(ctx, location, f, skip+limit) 240 242 if err != nil { 241 243 return ingredients, nil
+3 -3
internal/recipes/generator_hash_test.go
··· 23 23 } 24 24 25 25 // make sure we're intentional about breaking hash 26 - if h1 != "5paGKJp_BFc" { 27 - t.Fatalf("expected hash to be stable and equal to 5paGKJp_BFc, got %s", h1) 26 + if h1 != "JjKXkKjKKpE" { 27 + t.Fatalf("expected hash to be stable and equal to JjKXkKjKKpE, got %s", h1) 28 28 } 29 29 30 30 legacyHash, ok := legacyRecipeHash(h1) 31 31 if !ok { 32 32 t.Fatal("expected current hash passhed to legacy") 33 33 } 34 - if legacyHash != "cmVjaXBl5paGKJp_BFc=" { 34 + if legacyHash != "cmVjaXBlJjKXkKjKKpE=" { 35 35 t.Fatalf("expected legacy hash to be base64 of recipe hash with prefix, got %s", legacyHash) 36 36 } 37 37
+13 -3
internal/recipes/params.go
··· 190 190 } 191 191 192 192 func DefaultStaples() []filter { 193 - return []filter{ 193 + return append(Produce(), []filter{ 194 194 { 195 195 Term: "beef", 196 196 Brands: []string{"Simple Truth", "Kroger"}, ··· 215 215 Term: "lamb", 216 216 Brands: []string{"Simple Truth"}, 217 217 }, 218 + }...) 219 + } 220 + 221 + func Produce() []filter { 222 + return []filter{ 218 223 { 219 - Term: "produce vegetable", 220 - Brands: []string{"*"}, // ther's alot of fresh * and kroger here. cut this down after 500 sadness 224 + Term: "fresh vegatable", 225 + Brands: []string{"*"}, 226 + }, 227 + { 228 + Term: "fresh produce", 229 + Brands: []string{"*"}, 221 230 }, 222 231 } 232 + 223 233 } 224 234 225 235 func resolveStoreTimeLocation(ctx context.Context, l *locations.Location) (*time.Location, error) {