ai cooking
0
fork

Configure Feed

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

Make produce check work with staples. (#348)

* okay got basic listing

* seems we got this working lets move on to produce check

* produce check working have some results

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
71b8bb15 30a4b59f

+98 -87
+15 -2
cmd/ingredients/main.go
··· 2 2 3 3 import ( 4 4 "careme/internal/config" 5 + "careme/internal/kroger" 5 6 "careme/internal/recipes" 6 7 "context" 7 8 "flag" ··· 29 30 log.Fatalf("failed to create recipe generator: %s", err) 30 31 } 31 32 32 - ings, err := sp.GetIngredients(ctx, location, searchTerm, 0) 33 + var ings []kroger.Ingredient 34 + if searchTerm == "" { 35 + ings, err = sp.FetchStaples(ctx, location) 36 + } else { 37 + ings, err = sp.GetIngredients(ctx, location, searchTerm, 0) 38 + } 33 39 if err != nil { 34 40 log.Fatalf("failed to get ingredients: %s", err) 35 41 } 36 42 43 + catMap := make(map[string]int) 37 44 for _, i := range ings { 38 - fmt.Printf("%s: %s - %s:($%s) size: %s\n", toString(i.ProductId), toString(i.Brand), toString(i.Description), toFloat(i.PriceRegular), toString(i.Size)) 45 + for _, cat := range *i.Categories { 46 + catMap[cat] += 1 47 + } 48 + 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) 49 + } 50 + for cat, count := range catMap { 51 + fmt.Printf("Category: %s, Count: %d\n", cat, count) 39 52 } 40 53 } 41 54
+38 -64
cmd/producecheck/main.go
··· 3 3 import ( 4 4 "careme/internal/config" 5 5 "careme/internal/kroger" 6 + "careme/internal/recipes" 6 7 "context" 7 8 "flag" 8 9 "fmt" 9 10 "log" 10 11 "slices" 11 12 "strings" 12 - "sync" 13 13 "unicode" 14 14 15 15 "github.com/samber/lo" 16 16 "golang.org/x/text/unicode/norm" 17 17 ) 18 + 19 + type staplesProvider interface { 20 + FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 21 + } 18 22 19 23 func main() { 20 24 var locationID string ··· 41 45 log.Fatalf("failed to load config: %v", err) 42 46 } 43 47 44 - client, err := kroger.FromConfig(cfg) 48 + staples, err := recipes.NewStaplesProvider(cfg) 45 49 if err != nil { 46 - log.Fatalf("failed to create Kroger client: %v", err) 50 + log.Fatalf("failed to create staples provider: %v", err) 47 51 } 48 52 49 - missing, results, err := checkProduceAvailability(ctx, client, locationID, produce) 53 + missing, results, err := checkProduceAvailability(ctx, staples, locationID, produce) 50 54 if err != nil { 51 55 log.Fatalf("availability check failed: %v", err) 52 56 } ··· 85 89 matchedDescriptions []string 86 90 } 87 91 88 - func checkProduceAvailability(ctx context.Context, client kroger.ClientWithResponsesInterface, locationID string, produce []string) ([]string, int, error) { 92 + func checkProduceAvailability(ctx context.Context, client staplesProvider, locationID string, produce []string) ([]string, int, error) { 89 93 90 - //TODO expand this to other staple providers 91 - filters := kroger.ProduceFilters() 92 - type filterResult struct { 93 - filter string 94 - ingredients []kroger.Ingredient 95 - err error 96 - } 97 94 //todo check total number of queries. 98 - results := make([]filterResult, len(filters)) 99 - var wg sync.WaitGroup 100 - wg.Add(len(filters)) 101 - kprovider := kroger.NewStaplesProvider(client) 102 - for i, filter := range filters { 103 - go func() { 104 - defer wg.Done() 105 - filterIngredients, err := kprovider.GetIngredients(ctx, locationID, filter.Term, 0) 106 - results[i] = filterResult{ 107 - filter: filter.Term, 108 - ingredients: filterIngredients, 109 - err: err, 110 - } 111 - }() 95 + 96 + ingredients, err := client.FetchStaples(ctx, locationID) 97 + if err != nil { 98 + log.Fatalf("warning: failed to fetch staples ingredients for location %s: %v", locationID, err) 112 99 } 113 - wg.Wait() 114 - ingredients := make([]kroger.Ingredient, 0, 300) 115 - stats := make([]produceFilterStats, 0, len(filters)) 116 - for _, result := range results { 117 - if result.err != nil { 118 - log.Printf("warning: failed to get ingredients for filter %q at location %s: %v", result.filter, locationID, result.err) 119 - continue 120 - } 121 - matchedTerms, matchedProducts, matchedDescriptions := summarizeFilterMatchesDetailed(produce, result.ingredients) 122 - stats = append(stats, produceFilterStats{ 123 - FilterTerm: result.filter, 124 - IngredientMatches: len(result.ingredients), 125 - ProduceTermsMatched: matchedTerms, 126 - ProduceMatches: matchedProducts, 127 - matchedDescriptions: matchedDescriptions, 128 - }) 129 - ingredients = append(ingredients, result.ingredients...) 100 + matchedTerms, matchedProducts, matchedDescriptions := summarizeFilterMatchesDetailed(produce, ingredients) 101 + stats := produceFilterStats{ 102 + FilterTerm: "*", 103 + IngredientMatches: len(ingredients), 104 + ProduceTermsMatched: matchedTerms, 105 + ProduceMatches: matchedProducts, 106 + matchedDescriptions: matchedDescriptions, 130 107 } 131 - ingredients = lo.UniqBy(ingredients, func(i kroger.Ingredient) string { return toString(i.Description) }) 132 108 133 - annotateUniqueOnlyMatches(stats) 109 + ingredients = lo.UniqBy(ingredients, func(i kroger.Ingredient) string { 110 + return toString(i.ProductId) 111 + }) 112 + 113 + // TODO have staples return subset of ingredient that can say the search term it go a match on 114 + // then we can give info on whats coming from what queries. Could use categories for wholefoods. 115 + //annotateUniqueOnlyMatches(stats) 134 116 printProduceFilterSummary(stats, len(produce)) 135 117 136 118 return evaluateProduceAvailability(produce, ingredients), len(ingredients), nil ··· 188 170 } 189 171 190 172 // see what filters return results NOT seen elsewhere then update UniqueOnlyMatches 191 - func annotateUniqueOnlyMatches(stats []produceFilterStats) { 173 + /*func annotateUniqueOnlyMatches(stats []produceFilterStats) { 192 174 descriptionCount := make(map[string]int) 193 175 for _, stat := range stats { 194 176 for _, description := range stat.matchedDescriptions { ··· 205 187 } 206 188 stats[i].UniqueOnlyMatches = uniqueOnly 207 189 } 208 - } 190 + }*/ 209 191 210 - func printProduceFilterSummary(stats []produceFilterStats, totalProduceTerms int) { 211 - if len(stats) == 0 { 212 - fmt.Println("produce filter summary: no filters returned results") 213 - return 214 - } 192 + func printProduceFilterSummary(stat produceFilterStats, totalProduceTerms int) { 215 193 216 - fmt.Println() 217 - fmt.Println("produce filter summary:") 218 - for _, stat := range stats { 219 - fmt.Printf("- %s -> %d ingredients, %d/%d produce terms, %d matches, %d unique-only products\n", 220 - stat.FilterTerm, 221 - stat.IngredientMatches, 222 - stat.ProduceTermsMatched, 223 - totalProduceTerms, 224 - stat.ProduceMatches, 225 - stat.UniqueOnlyMatches, 226 - ) 227 - } 194 + fmt.Printf("- %s -> %d ingredients, %d/%d produce terms, %d matches, %d unique-only products\n", 195 + stat.FilterTerm, 196 + stat.IngredientMatches, 197 + stat.ProduceTermsMatched, 198 + totalProduceTerms, 199 + stat.ProduceMatches, 200 + stat.UniqueOnlyMatches, 201 + ) 228 202 fmt.Println() 229 203 } 230 204
+2 -2
cmd/producecheck/main_test.go
··· 107 107 } 108 108 } 109 109 110 - func TestAnnotateUniqueOnlyMatches(t *testing.T) { 110 + /*func TestAnnotateUniqueOnlyMatches(t *testing.T) { 111 111 stats := []produceFilterStats{ 112 112 { 113 113 FilterTerm: "fresh produce", ··· 134 134 if stats[2].UniqueOnlyMatches != 1 { 135 135 t.Fatalf("stats[2].UniqueOnlyMatches = %d, want %d", stats[2].UniqueOnlyMatches, 1) 136 136 } 137 - } 137 + }*/ 138 138 139 139 func strPtr(s string) *string { 140 140 return &s
+16
cmd/producecheck/results.txt
··· 1 + 03/10/26 results 2 + 3 + QFC 70500874 4 + - * -> 776 ingredients, 86/107 produce terms, 373 matches, 0 unique-only products 5 + missing produce for location 70500874: alfalfa sprout, aloe vera leaf, arugula, baby arugula, bean sprout, broccoli sprout, celery root, chive, curly kale, daikon radish, green chili pepper, horseradish root, king trumpet mushroom, lemongrass, little gem lettuce, mixed sprout, oyster mushroom, radicchio, red chili pepper, sliced mushroom blend, tarragon 6 + Produce score 0.803738: 86/107 with 644 ingredients 7 + 8 + safeway sample 9 + - * -> 460 ingredients, 62/107 produce terms, 161 matches, 0 unique-only products 10 + missing produce for location safeway_01: alfalfa sprout, aloe vera leaf, anaheim pepper, baby bok choy, baby spring mix, banana, bean sprout, blackberry, bok choy, butterhead lettuce, cantaloupe, celery root, curly kale, daikon radish, golden beet, green chili pepper, habanero pepper, honeydew melon, horseradish root, italian parsley, jalapeno pepper, jicama, king trumpet mushroom, lemongrass, little gem lettuce, mixed sprout, napa cabbage, orange bell pepper, parsnip, pear, poblano pepper, portabella mushroom, radicchio, rainbow chard, raspberry, red chili pepper, romaine lettuce, rutabaga, sage, seedless cucumber, serrano pepper, sliced mushroom blend, tomatillo, turnip, yuca 11 + Produce score 0.579439: 62/107 with 460 ingredients 12 + 13 + wholefoods_10153 14 + - * -> 582 ingredients, 88/107 produce terms, 318 matches, 0 unique-only products 15 + missing produce for location wholefoods_10153: aloe vera leaf, bean sprout, broccolini, curly kale, golden beet, green bean, horseradish root, king trumpet mushroom, lacinato kale, lemongrass, little gem lettuce, mixed sprout, poblano pepper, red chili pepper, rutabaga, seedless cucumber, sliced mushroom blend, sweet corn, yuca 16 + Produce score 0.822430: 88/107 with 575 ingredients
+11 -2
internal/actowiz/readme.md
··· 1 1 Can filter out availabity: false or i can do it on my side 2 + "Product Name": "N/A",? 3 + 2026/03/10 14:37:25 INFO Loaded safeway safeway_count=750 filtered_count=460 2 4 3 - How am I getting the list of stores. 4 5 6 + How am I getting the list of stores. 5 7 If data is per store I don't really need store info in each item. 6 8 7 9 Seperate Brand from product name? 10 + 11 + 12 + Don't need salami and lunch meets waste of tokens. 8 13 9 14 "Product Name": "N/A",? 10 15 11 - Wine? 16 + 2026/03/10 14:37:25 INFO Loaded safeway safeway_count=750 filtered_count=460 17 + 18 + 19 + Are you able to get wine. 20 +
+2
internal/actowiz/staples.go
··· 5 5 _ "embed" 6 6 "encoding/json" 7 7 "fmt" 8 + "log/slog" 8 9 "slices" 9 10 "strconv" 10 11 "strings" ··· 33 34 } 34 35 35 36 func NewStaplesProvider() StaplesProvider { 37 + slog.Info("Loaded safeway", "safeway_count", len(embeddedSafewayProducts), "filtered_count", len(all())) 36 38 return StaplesProvider{} 37 39 } 38 40
+1
internal/actowiz/types.go
··· 21 21 Availability bool `json:"Availability"` 22 22 } 23 23 24 + // custom marshalling mostly to handle fact that prices get "N/A" sometimes 24 25 func (p *SafewayProduct) UnmarshalJSON(data []byte) error { 25 26 type rawSafewayProduct struct { 26 27 StoreName string `json:"Store Name"`
+3 -1
internal/recipes/generator.go
··· 181 181 slog.ErrorContext(ctx, "failed to read cached ingredients", "location", p.String(), "error", err) 182 182 } 183 183 184 - ingredients, err := g.staplesProvider.FetchStaples(ctx, p.Location) 184 + ingredients, err := g.staplesProvider.FetchStaples(ctx, p.Location.ID) 185 185 if err != nil { 186 186 return nil, fmt.Errorf("failed to get ingredients for staples: %w", err) 187 187 } 188 + //should this be pushed down into staple proivder? go off product id? 188 189 ingredients = uniqueByDescription(ingredients) 190 + 189 191 mutable.Shuffle(ingredients) 190 192 191 193 if err := g.io.SaveIngredients(ctx, p.LocationHash(), ingredients); err != nil {
+1 -1
internal/recipes/generator_test.go
··· 53 53 return nil 54 54 } 55 55 56 - func (s *captureWineStaplesProvider) FetchStaples(ctx context.Context, location *locations.Location) ([]kroger.Ingredient, error) { 56 + func (s *captureWineStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 57 57 panic("unexpected call to FetchStaples") 58 58 } 59 59
+5 -11
internal/recipes/staples.go
··· 4 4 "careme/internal/actowiz" 5 5 "careme/internal/config" 6 6 "careme/internal/kroger" 7 - "careme/internal/locations" 8 7 "careme/internal/walmart" 9 8 "careme/internal/wholefoods" 10 9 "context" ··· 14 13 15 14 // todo make this a indepenedent ingredient object not kroger. 16 15 type staplesProvider interface { 17 - FetchStaples(ctx context.Context, location *locations.Location) ([]kroger.Ingredient, error) 16 + FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 18 17 GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) 19 18 } 20 19 ··· 30 29 type backendStaplesProvider interface { 31 30 IsID(locationID string) bool 32 31 Signature() string 33 - FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) 34 - GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) 32 + staplesProvider 35 33 } 36 34 37 35 func NewStaplesProvider(cfg *config.Config) (staplesProvider, error) { ··· 44 42 }, nil 45 43 } 46 44 47 - func (p routingStaplesProvider) FetchStaples(ctx context.Context, location *locations.Location) ([]kroger.Ingredient, error) { 48 - if location == nil { 49 - return nil, fmt.Errorf("location is required") 50 - } 51 - 52 - provider, err := p.providerForLocation(location.ID) 45 + func (p routingStaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) { 46 + provider, err := p.providerForLocation(locationID) 53 47 if err != nil { 54 48 return nil, err 55 49 } 56 - return provider.FetchStaples(ctx, location.ID) 50 + return provider.FetchStaples(ctx, locationID) 57 51 } 58 52 59 53 func (p routingStaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) {
+4 -4
internal/recipes/staples_test.go
··· 44 44 calls int 45 45 } 46 46 47 - func (s *stubRoutingStaplesProvider) FetchStaples(_ context.Context, _ *locations.Location) ([]kroger.Ingredient, error) { 47 + func (s *stubRoutingStaplesProvider) FetchStaples(_ context.Context, _ string) ([]kroger.Ingredient, error) { 48 48 s.calls++ 49 49 if s.err != nil { 50 50 return nil, s.err ··· 67 67 backends: []backendStaplesProvider{krogerProvider, wholeFoodsProvider}, 68 68 } 69 69 70 - if _, err := provider.FetchStaples(t.Context(), &locations.Location{ID: "70100023"}); err != nil { 70 + if _, err := provider.FetchStaples(t.Context(), "70100023"); err != nil { 71 71 t.Fatalf("FetchStaples kroger returned error: %v", err) 72 72 } 73 73 if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 0 { 74 74 t.Fatalf("expected kroger provider to be selected, got kroger=%d wholefoods=%d", krogerProvider.calls, wholeFoodsProvider.calls) 75 75 } 76 76 77 - if _, err := provider.FetchStaples(t.Context(), &locations.Location{ID: "wholefoods_10216"}); err != nil { 77 + if _, err := provider.FetchStaples(t.Context(), "wholefoods_10216"); err != nil { 78 78 t.Fatalf("FetchStaples whole foods returned error: %v", err) 79 79 } 80 80 if krogerProvider.calls != 1 || wholeFoodsProvider.calls != 1 { ··· 90 90 }, 91 91 } 92 92 93 - _, err := provider.FetchStaples(t.Context(), &locations.Location{ID: "walmart_3098"}) 93 + _, err := provider.FetchStaples(t.Context(), "walmart_3098") 94 94 if err == nil { 95 95 t.Fatal("expected unsupported backend error") 96 96 }