ai cooking
0
fork

Configure Feed

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

Merge pull request #72 from paulgmiller/conversations

Conversations

authored by

Paul Miller and committed by
GitHub
543178a2 bf4290d1

+157 -52
+35
AGENTS.md
··· 1 + # Repository Guidelines 2 + 3 + ## Project Structure & Module Organization 4 + - `cmd/careme`: Entry point; `main.go` parses flags for CLI vs `-serve` web mode; `web.go` wires handlers and middleware. 5 + - `internal/recipes`, `internal/locations`, `internal/kroger`: Business logic for meal planning, location lookup, and Kroger API access; generated client files live under `internal/kroger`. 6 + - `internal/templates` and `cmd/careme/favicon.png`: HTML templates and assets for the UI; `internal/html` holds helpers (e.g., Clarity snippet). 7 + - `internal/cache`, `internal/logsink`, `internal/ai`, `internal/users`: Cross-cutting services (caching, logging, AI provider glue, user storage). 8 + - `recipes/`: Local output directory created at runtime; keep it out of commits unless intentionally adding fixtures. 9 + 10 + ## Build, Test, and Development Commands 11 + - `go fmt ./...` then `go vet ./...`: Baseline formatting and static checks. 12 + - `go test ./...`: Run unit tests across all packages; add `-cover` when changing core logic. 13 + - `go run ./cmd/careme -serve -addr :8080`: Start the web server (requires env vars below). 14 + - `go run ./cmd/careme -zipcode 98101`: Helper to list Kroger location IDs by ZIP. 15 + - `go build -o bin/careme ./cmd/careme`: Produce a local binary for manual runs. 16 + 17 + ## Coding Style & Naming Conventions 18 + - Go 1.24; keep code `gofmt`-clean before review. Favor small, focused functions and table-driven tests. 19 + - Exported identifiers in `CamelCase`; package-private helpers in `lowerCamel`. Template names mirror file names in `internal/templates`. 20 + - Prefer standard library first; add dependencies sparingly and record rationale in PR description if new. 21 + - Prefer simple html to javascript frameworks 22 + 23 + ## Testing Guidelines 24 + - Place tests alongside code in `*_test.go`; prefer table-driven cases and explicit fixtures over implicit globals. 25 + - Use `go test ./... -run TestName` for targeted debugging; keep deterministic by avoiding network calls and using fakes where possible. 26 + - 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`). 27 + 28 + ## Commit & Pull Request Guidelines 29 + - Reference an issue/PR number when applicable. Say why something was done rather than just what was done. 30 + - In PRs, include: what changed, why, how to verify (commands run), and any config/env impacts. Add screenshots for UI changes using `internal/templates`. 31 + - Keep commits scoped and reviewable; avoid mixing refactors with feature changes unless necessary. 32 + 33 + ## Security & Configuration Notes 34 + - 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`. 35 + - 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
··· 15 15 16 16 "github.com/alpkeskin/gotoon" 17 17 openai "github.com/openai/openai-go/v3" 18 + "github.com/openai/openai-go/v3/conversations" 18 19 "github.com/openai/openai-go/v3/option" 19 20 "github.com/openai/openai-go/v3/responses" 20 21 "github.com/samber/lo" ··· 56 57 } 57 58 58 59 type ShoppingList struct { 59 - Recipes []Recipe `json:"recipes"` 60 + ConversationID string `json:"conversation_id,omitempty" jsonschema:"-"` 61 + Recipes []Recipe `json:"recipes" jsonschema:"required"` 60 62 } 61 63 62 64 func NewClient(provider, apiKey, model string) *Client { ··· 104 106 # Planning & Verification 105 107 - Before generating each recipe, reference your checklist to ensure variety in cooking methods and cuisines, and confirm ingredient prioritization matches sale/seasonal data.` 106 108 109 + func responseToShoppingList(ctx context.Context, resp *responses.Response) (*ShoppingList, error) { 110 + slog.InfoContext(ctx, "API usage", slog.Any("usage", json.RawMessage(resp.Usage.RawJSON()))) 111 + var shoppingList ShoppingList 112 + if err := json.Unmarshal([]byte(resp.OutputText()), &shoppingList); err != nil { 113 + return nil, fmt.Errorf("failed to parse AI response: %w", err) 114 + } 115 + if resp.Conversation.ID == "" { 116 + return nil, fmt.Errorf("failed to get conversation ID") 117 + } 118 + shoppingList.ConversationID = resp.Conversation.ID 119 + 120 + return &shoppingList, nil 121 + } 122 + 123 + func scheme(schema map[string]any) responses.ResponseTextConfigParam { 124 + return responses.ResponseTextConfigParam{ 125 + Format: responses.ResponseFormatTextConfigUnionParam{ 126 + OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ 127 + Name: "recipes", 128 + Schema: schema, //https://platform.openai.com/docs/guides/structured-outputs?example=structured-data 129 + }, 130 + }, 131 + } 132 + } 133 + 134 + func (c *Client) Regenerate(ctx context.Context, newInstruction string, conversationID string) (*ShoppingList, error) { 135 + if conversationID == "" { 136 + return nil, fmt.Errorf("conversation ID is required for regeneration") 137 + } 138 + client := openai.NewClient(option.WithAPIKey(c.apiKey)) 139 + 140 + params := responses.ResponseNewParams{ 141 + Model: openai.ChatModelGPT5_1, 142 + //only new input 143 + Input: responses.ResponseNewParamsInputUnion{ 144 + OfInputItemList: []responses.ResponseInputItemUnionParam{user(newInstruction)}, 145 + }, 146 + Store: openai.Bool(true), 147 + Conversation: responses.ResponseNewParamsConversationUnion{ 148 + OfString: openai.String(conversationID), 149 + }, 150 + Text: scheme(c.schema), 151 + } 152 + resp, err := client.Responses.New(ctx, params) 153 + if err != nil { 154 + return nil, fmt.Errorf("failed to regenerate recipes: %w", err) 155 + } 156 + 157 + return responseToShoppingList(ctx, resp) 158 + } 159 + 107 160 // is this dependency on krorger unncessary? just pass in a blob of toml or whatever? same with last recipes? 108 161 func (c *Client) GenerateRecipes(ctx context.Context, location *locations.Location, saleIngredients []kroger.Ingredient, instructions string, date time.Time, lastRecipes []string) (*ShoppingList, error) { 109 162 messages, err := c.buildRecipeMessages(location, saleIngredients, instructions, date, lastRecipes) ··· 112 165 } 113 166 114 167 client := openai.NewClient(option.WithAPIKey(c.apiKey)) 168 + convo, err := client.Conversations.New(ctx, conversations.ConversationNewParams{}) 169 + if err != nil { 170 + return nil, fmt.Errorf("failed to create conversation: %w", err) 171 + } 115 172 116 173 params := responses.ResponseNewParams{ 117 174 Model: openai.ChatModelGPT5_1, ··· 120 177 Input: responses.ResponseNewParamsInputUnion{ 121 178 OfInputItemList: messages, 122 179 }, 123 - Text: responses.ResponseTextConfigParam{ 124 - Format: responses.ResponseFormatTextConfigUnionParam{ 125 - OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ 126 - Name: "recipes", 127 - Schema: c.schema, //https://platform.openai.com/docs/guides/structured-outputs?example=structured-data 128 - }, 180 + Store: openai.Bool(true), 181 + Conversation: responses.ResponseNewParamsConversationUnion{ 182 + OfConversationObject: &responses.ResponseConversationParam{ 183 + ID: convo.ID, 129 184 }, 130 185 }, 131 - 132 - //should we stream. Can we pass past generation. 186 + Text: scheme(c.schema), 133 187 } 188 + //should we stream. Can we pass past generation. 134 189 135 190 resp, err := client.Responses.New(ctx, params) 136 191 if err != nil { 137 192 return nil, fmt.Errorf("failed to generate recipes: %w", err) 138 193 } 139 - slog.InfoContext(ctx, "API usage", slog.Any("usage", json.RawMessage(resp.Usage.RawJSON()))) 140 - 141 - // Parse the response to save recipes separately 142 - var shoppingList ShoppingList 143 - if err := json.Unmarshal([]byte(resp.OutputText()), &shoppingList); err != nil { 144 - slog.ErrorContext(ctx, "failed to parse AI response", "error", err) 145 - // Fall back to saving the entire response as before 146 - return nil, err 147 - } 148 - 149 - return &shoppingList, nil 194 + return responseToShoppingList(ctx, resp) 150 195 } 151 196 152 197 func user(msg string) responses.ResponseInputItemUnionParam {
+38 -19
internal/recipes/generator.go
··· 24 24 25 25 type aiClient interface { 26 26 GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) 27 + Regenerate(ctx context.Context, newinstruction string, conversationID string) (*ai.ShoppingList, error) 27 28 } 28 29 29 30 type Generator struct { ··· 69 70 Date time.Time `json:"date,omitempty"` 70 71 Staples []filter `json:"staples,omitempty"` 71 72 //People int 72 - Instructions string `json:"instructions,omitempty"` 73 - LastRecipes []string `json:"last_recipes,omitempty"` 74 - UserID string `json:"user_id,omitempty"` 73 + Instructions string `json:"instructions,omitempty"` 74 + LastRecipes []string `json:"last_recipes,omitempty"` 75 + UserID string `json:"user_id,omitempty"` 76 + ConversationID string `json:"conversation_id,omitempty"` //Can remove if we pass it in seperately to generate recipes? 75 77 } 76 78 77 79 func DefaultParams(l *locations.Location, date time.Time) *generatorParams { ··· 96 98 lo.Must(fnv.Write([]byte(g.Date.Format("2006-01-02")))) 97 99 bytes := lo.Must(json.Marshal(g.Staples)) 98 100 lo.Must(fnv.Write(bytes)) 99 - lo.Must(fnv.Write([]byte(g.Instructions))) 101 + 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? 100 102 return base64.URLEncoding.EncodeToString(fnv.Sum([]byte("recipe"))) 103 + //intionally not including ConversationID to preserve old hashes 101 104 } 102 105 103 106 // so far just excludes instructions. Can exclude people and other things ··· 157 160 defer done() 158 161 start := time.Now() 159 162 163 + var err error 164 + if p.ConversationID != "" && p.Instructions != "" { 165 + // these should both alwas be true. Warn if not because its a caching bug? 166 + shoppingList, err := g.aiClient.Regenerate(ctx, p.Instructions, p.ConversationID) 167 + if err != nil { 168 + return fmt.Errorf("failed to regenerate recipes with AI: %w", err) 169 + } 170 + slog.InfoContext(ctx, "regenerated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) 171 + return saveShoppingList(ctx, g.cache, shoppingList, p) 172 + } 173 + 160 174 ingredients, err := g.GetStaples(ctx, p) 161 175 if err != nil { 162 176 return fmt.Errorf("failed to get staples: %w", err) 163 177 } 164 - 165 178 shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, p.Instructions, p.Date, p.LastRecipes) 166 179 if err != nil { 167 180 return fmt.Errorf("failed to generate recipes with AI: %w", err) 168 181 } 169 - 170 182 slog.InfoContext(ctx, "generated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) 183 + if err := saveShoppingList(ctx, g.cache, shoppingList, p); err != nil { 184 + return fmt.Errorf("failed to save shopping list: %w", err) 185 + } 186 + // Also cache the params for hash-based retrieval 187 + // TODO: Consider embedding the params directly in the shoppingList structure. 188 + // This would allow us to cache both the shopping list and its associated parameters together, 189 + // avoiding the need for a separate cache entry for params (currently stored as "<hash>.params"). 190 + // Embedding params could simplify cache management and ensure all relevant data is retrieved together. 191 + // Persist the latest conversation IDs with the params so follow-ups can reuse them. 192 + p.ConversationID = shoppingList.ConversationID 193 + paramsJSON := lo.Must(json.Marshal(p)) 194 + if err := g.cache.Set(p.Hash()+".params", string(paramsJSON)); err != nil { 195 + slog.ErrorContext(ctx, "failed to cache params", "location", p.String(), "error", err) 196 + return err 197 + } 198 + return nil 199 + } 171 200 201 + func saveShoppingList(ctx context.Context, cache cache.Cache, shoppingList *ai.ShoppingList, p *generatorParams) error { 172 202 // Save each recipe separately by its hash 173 203 for i := range shoppingList.Recipes { 174 204 recipe := &shoppingList.Recipes[i] 175 205 recipe.OriginHash = p.Hash() 176 206 recipeJSON := lo.Must(json.Marshal(recipe)) 177 - if err := g.cache.Set("recipe/"+recipe.ComputeHash(), string(recipeJSON)); err != nil { 207 + if err := cache.Set("recipe/"+recipe.ComputeHash(), string(recipeJSON)); err != nil { 178 208 slog.ErrorContext(ctx, "failed to cache individual recipe", "recipe", recipe.Title, "error", err) 179 209 return err 180 210 } 181 211 } 182 212 //we could actually nuke out the rest of recipe and lazily load but not yet 183 213 shoppingJSON := lo.Must(json.Marshal(shoppingList)) 184 - if err := g.cache.Set(p.Hash(), string(shoppingJSON)); err != nil { 214 + if err := cache.Set(p.Hash(), string(shoppingJSON)); err != nil { 185 215 slog.ErrorContext(ctx, "failed to cache shopping list document", "location", p.String(), "error", err) 186 - return err 187 - } 188 - 189 - // Also cache the params for hash-based retrieval 190 - // TODO: Consider embedding the params directly in the shoppingList structure. 191 - // This would allow us to cache both the shopping list and its associated parameters together, 192 - // avoiding the need for a separate cache entry for params (currently stored as "<hash>.params"). 193 - // Embedding params could simplify cache management and ensure all relevant data is retrieved together. 194 - paramsJSON := lo.Must(json.Marshal(p)) 195 - if err := g.cache.Set(p.Hash()+".params", string(paramsJSON)); err != nil { 196 - slog.ErrorContext(ctx, "failed to cache params", "location", p.String(), "error", err) 197 216 return err 198 217 } 199 218
+14 -12
internal/recipes/html.go
··· 49 49 func (g *Generator) FormatChatHTML(p *generatorParams, l ai.ShoppingList, writer io.Writer) error { 50 50 //TODO just put params into shopping list and pass that up? 51 51 data := struct { 52 - Location locations.Location 53 - Date string 54 - ClarityScript template.HTML 55 - Instructions string 56 - Hash string 57 - Recipes []ai.Recipe 52 + Location locations.Location 53 + Date string 54 + ClarityScript template.HTML 55 + Instructions string 56 + Hash string 57 + Recipes []ai.Recipe 58 + ConversationID string 58 59 }{ 59 - Location: *p.Location, 60 - Date: p.Date.Format("2006-01-02"), 61 - ClarityScript: html.ClarityScript(g.config), 62 - Instructions: p.Instructions, 63 - Hash: p.Hash(), 64 - Recipes: l.Recipes, 60 + Location: *p.Location, 61 + Date: p.Date.Format("2006-01-02"), 62 + ClarityScript: html.ClarityScript(g.config), 63 + Instructions: p.Instructions, 64 + Hash: p.Hash(), 65 + Recipes: l.Recipes, 66 + ConversationID: l.ConversationID, 65 67 } 66 68 67 69 return templates.Recipe.Execute(writer, data)
+3
internal/recipes/server.go
··· 7 7 "html/template" 8 8 "log/slog" 9 9 "net/http" 10 + "strings" 10 11 "time" 11 12 12 13 "careme/internal/ai" ··· 197 198 s.generator.FormatChatHTML(p, *list, w) 198 199 return 199 200 } 201 + 202 + p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 200 203 201 204 go func() { 202 205 slog.InfoContext(ctx, "generating cached recipes", "params", p.String(), "hash", hash)
+2 -1
internal/templates/chat.html
··· 40 40 <form method="GET" class="flex flex-col gap-3 rounded-xl border border-brand-100 bg-brand-50/60 p-4 sm:flex-row sm:items-center"> 41 41 <input type="hidden" name="location" value="{{.Location.ID}}" /> 42 42 <input type="hidden" name="date" value="{{.Date}}" /> 43 + <input type="hidden" name="conversation_id" value="{{.ConversationID}}" /> 43 44 <label for="instructions" class="text-sm font-medium text-gray-700 sm:w-48">Extra instructions</label> 44 45 <input id="instructions" 45 46 type="text" ··· 136 137 } 137 138 </script> 138 139 </body> 139 - </html> 140 + </html>