···11+# Repository Guidelines
22+33+## Project Structure & Module Organization
44+- `cmd/careme`: Entry point; `main.go` parses flags for CLI vs `-serve` web mode; `web.go` wires handlers and middleware.
55+- `internal/recipes`, `internal/locations`, `internal/kroger`: Business logic for meal planning, location lookup, and Kroger API access; generated client files live under `internal/kroger`.
66+- `internal/templates` and `cmd/careme/favicon.png`: HTML templates and assets for the UI; `internal/html` holds helpers (e.g., Clarity snippet).
77+- `internal/cache`, `internal/logsink`, `internal/ai`, `internal/users`: Cross-cutting services (caching, logging, AI provider glue, user storage).
88+- `recipes/`: Local output directory created at runtime; keep it out of commits unless intentionally adding fixtures.
99+1010+## Build, Test, and Development Commands
1111+- `go fmt ./...` then `go vet ./...`: Baseline formatting and static checks.
1212+- `go test ./...`: Run unit tests across all packages; add `-cover` when changing core logic.
1313+- `go run ./cmd/careme -serve -addr :8080`: Start the web server (requires env vars below).
1414+- `go run ./cmd/careme -zipcode 98101`: Helper to list Kroger location IDs by ZIP.
1515+- `go build -o bin/careme ./cmd/careme`: Produce a local binary for manual runs.
1616+1717+## Coding Style & Naming Conventions
1818+- Go 1.24; keep code `gofmt`-clean before review. Favor small, focused functions and table-driven tests.
1919+- Exported identifiers in `CamelCase`; package-private helpers in `lowerCamel`. Template names mirror file names in `internal/templates`.
2020+- Prefer standard library first; add dependencies sparingly and record rationale in PR description if new.
2121+- Prefer simple html to javascript frameworks
2222+2323+## Testing Guidelines
2424+- Place tests alongside code in `*_test.go`; prefer table-driven cases and explicit fixtures over implicit globals.
2525+- Use `go test ./... -run TestName` for targeted debugging; keep deterministic by avoiding network calls and using fakes where possible.
2626+- 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`).
2727+2828+## Commit & Pull Request Guidelines
2929+- Reference an issue/PR number when applicable. Say why something was done rather than just what was done.
3030+- In PRs, include: what changed, why, how to verify (commands run), and any config/env impacts. Add screenshots for UI changes using `internal/templates`.
3131+- Keep commits scoped and reviewable; avoid mixing refactors with feature changes unless necessary.
3232+3333+## Security & Configuration Notes
3434+- Required env vars: `KROGER_CLIENT_ID`, `KROGER_CLIENT_SECRET`, `AI_API_KEY`; optional `AI_PROVIDER`, `AI_MODEL`, `CLARITY_PROJECT_ID`, `HISTORY_PATH`. Azure logging uses `AZURE_STORAGE_ACCOUNT_NAME` and `AZURE_STORAGE_PRIMARY_ACCOUNT_KEY`.
3535+- Never commit secrets or generated recipe outputs. If testing against real APIs, use minimal scopes and rotate keys promptly.
+65-20
internal/ai/client.go
···15151616 "github.com/alpkeskin/gotoon"
1717 openai "github.com/openai/openai-go/v3"
1818+ "github.com/openai/openai-go/v3/conversations"
1819 "github.com/openai/openai-go/v3/option"
1920 "github.com/openai/openai-go/v3/responses"
2021 "github.com/samber/lo"
···5657}
57585859type ShoppingList struct {
5959- Recipes []Recipe `json:"recipes"`
6060+ ConversationID string `json:"conversation_id,omitempty" jsonschema:"-"`
6161+ Recipes []Recipe `json:"recipes" jsonschema:"required"`
6062}
61636264func NewClient(provider, apiKey, model string) *Client {
···104106# Planning & Verification
105107- Before generating each recipe, reference your checklist to ensure variety in cooking methods and cuisines, and confirm ingredient prioritization matches sale/seasonal data.`
106108109109+func responseToShoppingList(ctx context.Context, resp *responses.Response) (*ShoppingList, error) {
110110+ slog.InfoContext(ctx, "API usage", slog.Any("usage", json.RawMessage(resp.Usage.RawJSON())))
111111+ var shoppingList ShoppingList
112112+ if err := json.Unmarshal([]byte(resp.OutputText()), &shoppingList); err != nil {
113113+ return nil, fmt.Errorf("failed to parse AI response: %w", err)
114114+ }
115115+ if resp.Conversation.ID == "" {
116116+ return nil, fmt.Errorf("failed to get conversation ID")
117117+ }
118118+ shoppingList.ConversationID = resp.Conversation.ID
119119+120120+ return &shoppingList, nil
121121+}
122122+123123+func scheme(schema map[string]any) responses.ResponseTextConfigParam {
124124+ return responses.ResponseTextConfigParam{
125125+ Format: responses.ResponseFormatTextConfigUnionParam{
126126+ OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{
127127+ Name: "recipes",
128128+ Schema: schema, //https://platform.openai.com/docs/guides/structured-outputs?example=structured-data
129129+ },
130130+ },
131131+ }
132132+}
133133+134134+func (c *Client) Regenerate(ctx context.Context, newInstruction string, conversationID string) (*ShoppingList, error) {
135135+ if conversationID == "" {
136136+ return nil, fmt.Errorf("conversation ID is required for regeneration")
137137+ }
138138+ client := openai.NewClient(option.WithAPIKey(c.apiKey))
139139+140140+ params := responses.ResponseNewParams{
141141+ Model: openai.ChatModelGPT5_1,
142142+ //only new input
143143+ Input: responses.ResponseNewParamsInputUnion{
144144+ OfInputItemList: []responses.ResponseInputItemUnionParam{user(newInstruction)},
145145+ },
146146+ Store: openai.Bool(true),
147147+ Conversation: responses.ResponseNewParamsConversationUnion{
148148+ OfString: openai.String(conversationID),
149149+ },
150150+ Text: scheme(c.schema),
151151+ }
152152+ resp, err := client.Responses.New(ctx, params)
153153+ if err != nil {
154154+ return nil, fmt.Errorf("failed to regenerate recipes: %w", err)
155155+ }
156156+157157+ return responseToShoppingList(ctx, resp)
158158+}
159159+107160// is this dependency on krorger unncessary? just pass in a blob of toml or whatever? same with last recipes?
108161func (c *Client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []kroger.Ingredient, instructions string, date time.Time, lastRecipes []string) (*ShoppingList, error) {
109162 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes)
···112165 }
113166114167 client := openai.NewClient(option.WithAPIKey(c.apiKey))
168168+ convo, err := client.Conversations.New(ctx, conversations.ConversationNewParams{})
169169+ if err != nil {
170170+ return nil, fmt.Errorf("failed to create conversation: %w", err)
171171+ }
115172116173 params := responses.ResponseNewParams{
117174 Model: openai.ChatModelGPT5_1,
···120177 Input: responses.ResponseNewParamsInputUnion{
121178 OfInputItemList: messages,
122179 },
123123- Text: responses.ResponseTextConfigParam{
124124- Format: responses.ResponseFormatTextConfigUnionParam{
125125- OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{
126126- Name: "recipes",
127127- Schema: c.schema, //https://platform.openai.com/docs/guides/structured-outputs?example=structured-data
128128- },
180180+ Store: openai.Bool(true),
181181+ Conversation: responses.ResponseNewParamsConversationUnion{
182182+ OfConversationObject: &responses.ResponseConversationParam{
183183+ ID: convo.ID,
129184 },
130185 },
131131-132132- //should we stream. Can we pass past generation.
186186+ Text: scheme(c.schema),
133187 }
188188+ //should we stream. Can we pass past generation.
134189135190 resp, err := client.Responses.New(ctx, params)
136191 if err != nil {
137192 return nil, fmt.Errorf("failed to generate recipes: %w", err)
138193 }
139139- slog.InfoContext(ctx, "API usage", slog.Any("usage", json.RawMessage(resp.Usage.RawJSON())))
140140-141141- // Parse the response to save recipes separately
142142- var shoppingList ShoppingList
143143- if err := json.Unmarshal([]byte(resp.OutputText()), &shoppingList); err != nil {
144144- slog.ErrorContext(ctx, "failed to parse AI response", "error", err)
145145- // Fall back to saving the entire response as before
146146- return nil, err
147147- }
148148-149149- return &shoppingList, nil
194194+ return responseToShoppingList(ctx, resp)
150195}
151196152197func user(msg string) responses.ResponseInputItemUnionParam {
+38-19
internal/recipes/generator.go
···24242525type aiClient interface {
2626 GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error)
2727+ Regenerate(ctx context.Context, newinstruction string, conversationID string) (*ai.ShoppingList, error)
2728}
28292930type Generator struct {
···6970 Date time.Time `json:"date,omitempty"`
7071 Staples []filter `json:"staples,omitempty"`
7172 //People int
7272- Instructions string `json:"instructions,omitempty"`
7373- LastRecipes []string `json:"last_recipes,omitempty"`
7474- UserID string `json:"user_id,omitempty"`
7373+ Instructions string `json:"instructions,omitempty"`
7474+ LastRecipes []string `json:"last_recipes,omitempty"`
7575+ UserID string `json:"user_id,omitempty"`
7676+ ConversationID string `json:"conversation_id,omitempty"` //Can remove if we pass it in seperately to generate recipes?
7577}
76787779func DefaultParams(l *locations.Location, date time.Time) *generatorParams {
···9698 lo.Must(fnv.Write([]byte(g.Date.Format("2006-01-02"))))
9799 bytes := lo.Must(json.Marshal(g.Staples))
98100 lo.Must(fnv.Write(bytes))
9999- lo.Must(fnv.Write([]byte(g.Instructions)))
101101+ lo.Must(fnv.Write([]byte(g.Instructions))) //rethink this? if they're all in convo should we have one id and ability to walk back?
100102 return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe")))
103103+ //intionally not including ConversationID to preserve old hashes
101104}
102105103106// so far just excludes instructions. Can exclude people and other things
···157160 defer done()
158161 start := time.Now()
159162163163+ var err error
164164+ if p.ConversationID != "" && p.Instructions != "" {
165165+ // these should both alwas be true. Warn if not because its a caching bug?
166166+ shoppingList, err := g.aiClient.Regenerate(ctx, p.Instructions, p.ConversationID)
167167+ if err != nil {
168168+ return fmt.Errorf("failed to regenerate recipes with AI: %w", err)
169169+ }
170170+ slog.InfoContext(ctx, "regenerated chat", "location", p.String(), "duration", time.Since(start), "hash", hash)
171171+ return saveShoppingList(ctx, g.cache, shoppingList, p)
172172+ }
173173+160174 ingredients, err := g.GetStaples(ctx, p)
161175 if err != nil {
162176 return fmt.Errorf("failed to get staples: %w", err)
163177 }
164164-165178 shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, p.Instructions, p.Date, p.LastRecipes)
166179 if err != nil {
167180 return fmt.Errorf("failed to generate recipes with AI: %w", err)
168181 }
169169-170182 slog.InfoContext(ctx, "generated chat", "location", p.String(), "duration", time.Since(start), "hash", hash)
183183+ if err := saveShoppingList(ctx, g.cache, shoppingList, p); err != nil {
184184+ return fmt.Errorf("failed to save shopping list: %w", err)
185185+ }
186186+ // Also cache the params for hash-based retrieval
187187+ // TODO: Consider embedding the params directly in the shoppingList structure.
188188+ // This would allow us to cache both the shopping list and its associated parameters together,
189189+ // avoiding the need for a separate cache entry for params (currently stored as "<hash>.params").
190190+ // Embedding params could simplify cache management and ensure all relevant data is retrieved together.
191191+ // Persist the latest conversation IDs with the params so follow-ups can reuse them.
192192+ p.ConversationID = shoppingList.ConversationID
193193+ paramsJSON := lo.Must(json.Marshal(p))
194194+ if err := g.cache.Set(p.Hash()+".params", string(paramsJSON)); err != nil {
195195+ slog.ErrorContext(ctx, "failed to cache params", "location", p.String(), "error", err)
196196+ return err
197197+ }
198198+ return nil
199199+}
171200201201+func saveShoppingList(ctx context.Context, cache cache.Cache, shoppingList *ai.ShoppingList, p *generatorParams) error {
172202 // Save each recipe separately by its hash
173203 for i := range shoppingList.Recipes {
174204 recipe := &shoppingList.Recipes[i]
175205 recipe.OriginHash = p.Hash()
176206 recipeJSON := lo.Must(json.Marshal(recipe))
177177- if err := g.cache.Set("recipe/"+recipe.ComputeHash(), string(recipeJSON)); err != nil {
207207+ if err := cache.Set("recipe/"+recipe.ComputeHash(), string(recipeJSON)); err != nil {
178208 slog.ErrorContext(ctx, "failed to cache individual recipe", "recipe", recipe.Title, "error", err)
179209 return err
180210 }
181211 }
182212 //we could actually nuke out the rest of recipe and lazily load but not yet
183213 shoppingJSON := lo.Must(json.Marshal(shoppingList))
184184- if err := g.cache.Set(p.Hash(), string(shoppingJSON)); err != nil {
214214+ if err := cache.Set(p.Hash(), string(shoppingJSON)); err != nil {
185215 slog.ErrorContext(ctx, "failed to cache shopping list document", "location", p.String(), "error", err)
186186- return err
187187- }
188188-189189- // Also cache the params for hash-based retrieval
190190- // TODO: Consider embedding the params directly in the shoppingList structure.
191191- // This would allow us to cache both the shopping list and its associated parameters together,
192192- // avoiding the need for a separate cache entry for params (currently stored as "<hash>.params").
193193- // Embedding params could simplify cache management and ensure all relevant data is retrieved together.
194194- paramsJSON := lo.Must(json.Marshal(p))
195195- if err := g.cache.Set(p.Hash()+".params", string(paramsJSON)); err != nil {
196196- slog.ErrorContext(ctx, "failed to cache params", "location", p.String(), "error", err)
197216 return err
198217 }
199218