···1111 "strings"
1212 "time"
13131414- "careme/internal/locations"
1414+ locationtypes "careme/internal/locations/types"
15151616 openai "github.com/openai/openai-go/v3"
1717 "github.com/openai/openai-go/v3/option"
···373373 return &selection, nil
374374}
375375376376-func (c *client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) {
376376+func (c *client) GenerateRecipes(ctx context.Context, location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (*ShoppingList, error) {
377377 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes)
378378 if err != nil {
379379 return nil, fmt.Errorf("failed to build recipe messages: %w", err)
···439439}
440440441441// buildRecipeMessages creates separate messages for the LLM to process more efficiently
442442-func (c *client) buildRecipeMessages(location *locations.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) {
442442+func (c *client) buildRecipeMessages(location *locationtypes.Location, saleIngredients []InputIngredient, instructions []string, date time.Time, lastRecipes []string) (responses.ResponseInputParam, error) {
443443 var messages []responses.ResponseInputItemUnionParam
444444 // constants we might make variable later
445445 messages = append(messages, user("Prioritize ingredients that are in season for the current date and user's state location "+date.Format("January 2nd")+" in "+location.State+"."))
+23-1
internal/ai/ingredient_grade.go
···2121 defaultIngredientGradeModel = openai.ChatModelGPT5Mini
2222)
23232424+// should we have category spefic grading prompts?
2425const ingredientGradeSystemInstruction = `
2526You review grocery catalog items before they are shown to a home recipe generator.
2627···3738- formats intended mainly for snacking or immediate eating rather than cooking
3839- pre-cut fruit unless it is still broadly useful for cooking or baking
39404141+Additional rules for pasta, grains, rice, legumes, and noodles:
4242+4343+- Prefer flexible base carbohydrates:
4444+ rice, dry pasta, oats, quinoa, farro, freekah
4545+4646+- Use simple score anchors:
4747+ standard dry pasta → 6–7
4848+ premium dry pasta → 8–9
4949+ alternative pasta (chickpea, lentil, gluten-free) → 5–6
5050+ bread → 5–6
5151+ prepared sauces → max 6
5252+ instant or flavored mixes → 3–5
5353+5454+- Reward real cooking-performance signals:
5555+ bronze-cut, slow-dried, high-protein durum, whole grain, hulled, pearled
5656+5757+- Reward known higher-quality brands (e.g., Felicetti, De Cecco, Rummo, Rustichella)
5858+5959+- Do not infer quality from generic terms:
6060+ "quality", "non-GMO", "organic", "traditional"
6161+6262+- Penalize items that are less flexible or more processed
40634164Scoring anchors:
4265- 9-10: excellent raw/fresh flexible cooking ingredient, e.g. whole vegetables, greens, roots, raw meats, fresh fruit useful in baking/cooking
···50735174Return JSON only. Preserve each input id/index exactly. Be concise.`
52755353-// this is wire compatible with kroger.Ingredient eventually it should replace it in what staples returns
5476type InputIngredient struct {
5577 ProductID string `json:"id,omitempty"`
5678 AisleNumber string `json:"number,omitempty"` // this is a dumb json name fix it later
+10-5
internal/albertsons/query/client.go
···1212 "time"
1313)
14141515+// this is a strange set. Actual sub categories don't work but thes aisle-vs ones do.
1616+// for en example broke sub category here is beef https://www.safeway.com/shop/aisles/meat-seafood/beef.html?sort=&page=1&loc=1142
1517const (
1616- Category_Vegatables = "GR-C-categ-8c62c848"
1717- Category_Fruit = "GR-C-categ-a8eea474"
1818- Category_Seafood = "GR-C-Categ-6090cd27"
1919- Category_Meat = "GR-MeatF-fffc8662"
2020- Category_Wine = "GR-S-Searc-db592d50"
1818+ Category_Vegatables = "GR-C-categ-8c62c848"
1919+ Category_Fruit = "GR-C-categ-a8eea474"
2020+ Category_Seafood = "GR-C-Categ-6090cd27" // https://www.safeway.com/aisle-vs/meat-seafood/seafood-favorites.html
2121+ Category_Meat = "GR-MeatF-fffc8662" // https://www.safeway.com/aisle-vs/meat-seafood/meat-favorites.html
2222+ Category_Wine = "GR-S-Searc-db592d50"
2323+ Category_Pasta_Grains = "GR-C-Categ-77b9d5dd" // https://www.safeway.com/aisle-vs/grains-pasta-sides/best-sellers.html
2424+ Category_Dairy = "GR-C-Categ-f210e5cd" // new and trending seems dubious https://www.safeway.com/aisle-vs/dairy-eggs-cheese/new-trending.html
2125)
22262327func StapleCategories() []string {
···2630 Category_Fruit,
2731 Category_Seafood,
2832 Category_Meat,
3333+ Category_Pasta_Grains,
2934 }
3035}
3136
+25-45
internal/albertsons/staples.go
···88 "net/http"
99 "strings"
10101111+ "careme/internal/ai"
1112 "careme/internal/albertsons/query"
1213 "careme/internal/cache"
1314 "careme/internal/config"
1414- "careme/internal/kroger"
1515 "careme/internal/parallelism"
16161717 "github.com/samber/lo"
···7373}
74747575var stapleRows = map[string]uint{
7676- query.Category_Vegatables: 150, // do we need way more of this?
7777- query.Category_Fruit: 100,
7878- query.Category_Meat: 100,
7979- query.Category_Seafood: 60,
7676+ query.Category_Vegatables: 150, // do we need way more of this?
7777+ query.Category_Fruit: 100,
7878+ query.Category_Meat: 100,
7979+ query.Category_Seafood: 60,
8080+ query.Category_Pasta_Grains: 100, //???
8081}
81828282-func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]kroger.Ingredient, error) {
8383+func (p StaplesProvider) FetchStaples(ctx context.Context, locationID string) ([]ai.InputIngredient, error) {
8384 client, storeID, err := p.clientForLocation(locationID)
8485 if err != nil {
8586 return nil, err
8687 }
87888888- return parallelism.Flatten(query.StapleCategories(), func(category string) ([]kroger.Ingredient, error) {
8989+ return parallelism.Flatten(query.StapleCategories(), func(category string) ([]ai.InputIngredient, error) {
8990 payload, err := client.Search(ctx, storeID, category, query.SearchOptions{
9091 // how many rows? different per category? Should we paginate
9192 Rows: stapleRows[category],
···9697 return nil, err
9798 }
98999999- ingredients := lo.Map(payload.Response.Docs, func(product query.PathwaySearchProduct, _ int) kroger.Ingredient {
100100- return productToIngredient(product)
101101- })
100100+ ingredients := lo.Map(payload.Response.Docs, productToIngredient)
102101 slog.InfoContext(ctx, "found albertsons staples for category", "count", len(ingredients), "category", category, "location", locationID)
103102 return ingredients, nil
104103 })
105104}
106105107106// since this is mostly used by wine it isn't actuallyt they helpful.
108108-func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]kroger.Ingredient, error) {
107107+func (p StaplesProvider) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int) ([]ai.InputIngredient, error) {
109108 client, storeID, err := p.clientForLocation(locationID)
110109 if err != nil {
111110 return nil, err
···113112114113 // should we just resturn all instead of search term? how many is this?
115114 payload, err := client.Search(ctx, storeID, query.Category_Wine, query.SearchOptions{
116116- Query: searchTerm, Rows: 100,
115115+ Query: searchTerm, Rows: 100, Start: uint(skip),
117116 })
118117 if err != nil {
119118 return nil, err
120119 }
121121-122122- ingredients := lo.Map(payload.Response.Docs, func(product query.PathwaySearchProduct, _ int) kroger.Ingredient {
123123- return productToIngredient(product)
124124- })
125125- if skip >= len(ingredients) {
126126- return []kroger.Ingredient{}, nil
127127- }
128128- return ingredients[skip:], nil
120120+ return lo.Map(payload.Response.Docs, productToIngredient), nil
129121}
130122131123// clientForLocation takes a prefixed store id and looks up chaing base url and returnes unprefixed id.
···156148 return "", "", false
157149}
158150159159-func productToIngredient(product query.PathwaySearchProduct) kroger.Ingredient {
160160- productID := stringPtr(strings.TrimSpace(product.ID))
161161- description := stringPtr(strings.TrimSpace(product.Name))
151151+func productToIngredient(product query.PathwaySearchProduct, _ int) ai.InputIngredient {
152152+ productID := strings.TrimSpace(product.ID)
153153+ description := strings.TrimSpace(product.Name)
162154 size := sizeText(product)
163155 regularPrice := float32Ptr(product.BasePrice)
164156 salePrice := float32Ptr(product.Price)
157157+ // how does shelf relate to aisle?
165158 categories := lo.Compact([]string{product.DepartmentName, product.ShelfName})
166159167167- var categoryPtr *[]string
168168- if len(categories) > 0 {
169169- categoryPtr = &categories
170170- }
171171-172172- return kroger.Ingredient{
173173- ProductId: productID,
160160+ return ai.NormalizeInputIngredient(ai.InputIngredient{
161161+ ProductID: productID,
174162 Description: description,
175175- Size: size,
163163+ Size: size, // will product id smash this as a dedupe?
176164 PriceRegular: regularPrice,
177165 PriceSale: salePrice,
178178- Categories: categoryPtr,
179179- }
166166+ Categories: categories,
167167+ AisleNumber: product.AisleID, // also an aisle name if thats better?
168168+ })
180169}
181170182171// this is a bit squirely shouldn't we take one ratehr than joiing both?
183183-func sizeText(product query.PathwaySearchProduct) *string {
172172+func sizeText(product query.PathwaySearchProduct) string {
184173 sizeParts := lo.Compact([]string{product.ItemSizeQty, product.UnitOfMeasure})
185174 if len(sizeParts) == 0 {
186186- return nil
175175+ return ""
187176 }
188188- size := strings.Join(sizeParts, " ")
189189- return &size
190190-}
191191-192192-func stringPtr(value string) *string {
193193- value = strings.TrimSpace(value)
194194- if value == "" {
195195- return nil
196196- }
197197- return &value
177177+ return strings.Join(sizeParts, " ")
198178}
199179200180func float32Ptr(value float64) *float32 {
···104104 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "response_id", p.ResponseID)
105105 instructions := regenerateInstructions(p)
106106107107+ // TODO give them some sort of status.
107108 shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ResponseID)
108109 if err != nil {
109110 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err)
···129130 if err != nil {
130131 return nil, fmt.Errorf("failed to get staples: %w", err)
131132 }
132132- g.writeStatus(ctx, hash, fmt.Sprintf("Looking through %d ingredients", len(ingredients)))
133133+ ogCount := len(ingredients)
133134 ingredients = lo.Filter(ingredients, func(ing ai.InputIngredient, _ int) bool {
134135 // TODO make configurable?
135135- return ing.Grade == nil || ing.Grade.Score > 5
136136+ return ing.Grade == nil || ing.Grade.Score > 6
136137 })
138138+ // having category would be interesing here.
139139+ g.writeStatus(ctx, hash, fmt.Sprintf("Considering %d out of %d ingredients", len(ingredients), ogCount))
140140+137141 mutable.Shuffle(ingredients)
138142139143 instructions := []string{p.Directive, p.Instructions}
+2-2
internal/recipes/generator_hash_test.go
···2323 }
24242525 // make sure we're intentional about breaking hash
2626- if h1 != "JjKXkKjKKpE" {
2626+ if h1 != "wrxx3dmHzBA" {
2727 t.Fatalf("expected hash to be stable and equal to JjKXkKjKKpE, got %s", h1)
2828 }
2929···3131 if !ok {
3232 t.Fatal("expected current hash passhed to legacy")
3333 }
3434- if legacyHash != "cmVjaXBlJjKXkKjKKpE=" {
3434+ if legacyHash != "cmVjaXBlwrxx3dmHzBA=" {
3535 t.Fatalf("expected legacy hash to be base64 of recipe hash with prefix, got %s", legacyHash)
3636 }
3737
-1
internal/recipes/io.go
···108108 }
109109 }()
110110111111- // this should be back compat with kroger.Ingredient
112111 var ingredients []ai.InputIngredient
113112 if err := json.NewDecoder(ingredientBlob).Decode(&ingredients); err != nil {
114113 return nil, err