ai cooking
0
fork

Configure Feed

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

Nosharedconvos (#507)

* use last response not shared conversation

* move origin hash out of save

* fix up test

* final touches

* use current shoppinglist

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
88bca347 572fdf98

+336 -265
+4 -4
cmd/careme/web_e2e_test.go
··· 84 84 85 85 // Step 6: ask a question on the finalized single recipe page. 86 86 question := "Can I use skirt steak instead?" 87 - conversationID := extractHiddenValue(t, mustGetBody(t, client, srv.URL+"/recipe/"+url.PathEscape(savedHash)), "conversation_id") 87 + responseID := extractHiddenValue(t, mustGetBody(t, client, srv.URL+"/recipe/"+url.PathEscape(savedHash)), "response_id") 88 88 questionURL := srv.URL + "/recipe/" + url.PathEscape(savedHash) + "/question" 89 89 questionBody := mustPostFormBodyHTMX(t, client, questionURL, url.Values{ 90 - "conversation_id": {conversationID}, 91 - "question": {question}, 92 - "recipe_title": {savedTitle}, 90 + "response_id": {responseID}, 91 + "question": {question}, 92 + "recipe_title": {savedTitle}, 93 93 }) 94 94 if !strings.Contains(questionBody, question) { 95 95 t.Fatalf("expected question thread to include question %q", question)
+4 -3
docs/data-object-flow.md
··· 14 14 - `location` (required) 15 15 - `date` (optional, defaulted by store timezone/day boundary) 16 16 - `instructions` (optional) 17 - - `conversation_id` (optional) 17 + - `response_id` (optional) 18 18 3. `handleRecipes` persists that object with `SaveParams(...)` under `params/<params_hash>`. 19 19 4. This saved `params` object is the start signal for generation. `kickgeneration(...)` is launched immediately after. 20 20 ··· 22 22 23 23 Async generation path: 24 24 1. `kickgeneration(...)` calls `generator.GenerateRecipes(ctx, params)`. 25 - 2. The generator returns an `ai.ShoppingList` containing `Recipes` (and `ConversationID`). 25 + 2. The generator returns an `ai.ShoppingList` containing `Recipes` (and `ResponseID`). 26 26 3. `SaveShoppingList(...)` persists: 27 27 - `shoppinglist/<params_hash>` -> full `ai.ShoppingList` 28 28 - `recipe/<recipe_hash>` -> each recipe object (with `OriginHash = params_hash`) ··· 55 55 1. Loads old `params` from `params/<hash>`. 56 56 2. Loads current `shoppingList` from `shoppinglist/<hash>`. 57 57 3. Loads `recipeSelection` for `(user_id, hash)`. 58 - 4. Merges selection state into params (`mergeParamsWithSelection`), applies new instructions, and carries conversation id when needed. 58 + 4. Merges selection state into params (`mergeParamsWithSelection`), applies new instructions, and carries the latest response id when needed. 59 59 5. Computes a new hash from the updated params. 60 60 61 61 Then: ··· 65 65 Result: 66 66 - `selection` holds transient decision state for a given origin hash. 67 67 - A new generation cycle begins when a new `params` object is created and saved. 68 + - Recipe follow-up questions are chained by the latest `response_id` stored on the recipe thread; each answer updates that thread-level response id for the next turn.
+42 -43
internal/ai/client.go
··· 15 15 "careme/internal/locations" 16 16 17 17 openai "github.com/openai/openai-go/v3" 18 - "github.com/openai/openai-go/v3/conversations" 19 18 "github.com/openai/openai-go/v3/option" 20 19 "github.com/openai/openai-go/v3/responses" 21 20 "github.com/samber/lo" ··· 52 51 Health string `json:"health"` 53 52 DrinkPairing string `json:"drink_pairing"` 54 53 WineStyles []string `json:"wine_styles"` 54 + ResponseID string `json:"response_id,omitempty" jsonschema:"-"` // not in schema 55 55 OriginHash string `json:"origin_hash,omitempty" jsonschema:"-"` // not in schema 56 56 ParentHash string `json:"parent_hash,omitempty" jsonschema:"-"` // regeneration metadata, not in schema 57 57 Saved bool `json:"previously_saved,omitempty" jsonschema:"-"` // not in schema ··· 80 80 return base64.URLEncoding.EncodeToString(fnv.Sum(nil)) 81 81 } 82 82 83 - // intionally not including ConversationID to preserve old hashes 83 + // intentionally not including ResponseID to preserve old hashes 84 + // we used to use conversation id here but then you can end up sharing conversations with strangers which is kind of wierd. 85 + // now we can reuse first recipes and people can go off in different directions. 84 86 type ShoppingList struct { 85 - ConversationID string `json:"conversation_id,omitempty" jsonschema:"-"` 86 - Recipes []Recipe `json:"recipes" jsonschema:"required"` 87 - Discarded []Recipe `json:"-" jsonschema:"-"` 87 + ResponseID string `json:"response_id,omitempty" jsonschema:"-"` 88 + Recipes []Recipe `json:"recipes" jsonschema:"required"` 89 + Discarded []Recipe `json:"-" jsonschema:"-"` 90 + } 91 + 92 + // question threads go off from the response that generated the recipe. 93 + type QuestionResponse struct { 94 + Answer string 95 + ResponseID string 88 96 } 89 97 90 98 type WineSelection struct { ··· 190 198 return nil, fmt.Errorf("failed to parse AI response: %w", err) 191 199 } 192 200 normalizeWineStyles(&shoppingList) 193 - if resp.Conversation.ID == "" { 194 - return nil, fmt.Errorf("failed to get conversation ID") 201 + if strings.TrimSpace(resp.ID) == "" { 202 + return nil, fmt.Errorf("failed to get response ID") 203 + } 204 + shoppingList.ResponseID = resp.ID 205 + for i := range shoppingList.Recipes { 206 + shoppingList.Recipes[i].ResponseID = shoppingList.ResponseID 195 207 } 196 - shoppingList.ConversationID = resp.Conversation.ID 197 208 198 209 return &shoppingList, nil 199 210 } ··· 209 220 } 210 221 } 211 222 212 - func (c *client) Regenerate(ctx context.Context, instructions []string, conversationID string) (*ShoppingList, error) { 213 - if conversationID == "" { 214 - return nil, fmt.Errorf("conversation ID is required for regeneration") 223 + func (c *client) Regenerate(ctx context.Context, instructions []string, previousResponseID string) (*ShoppingList, error) { 224 + if previousResponseID == "" { 225 + return nil, fmt.Errorf("response ID is required for regeneration") 215 226 } 216 227 client := openai.NewClient(option.WithAPIKey(c.apiKey)) 217 228 messages := cleanInstuctions(instructions) 218 229 219 230 params := responses.ResponseNewParams{ 220 - Model: c.model, 231 + Model: c.model, 232 + PreviousResponseID: openai.String(previousResponseID), 221 233 // only new input 222 234 Input: responses.ResponseNewParamsInputUnion{ 223 235 OfInputItemList: messages, 224 236 }, 225 237 Store: openai.Bool(true), 226 - Conversation: responses.ResponseNewParamsConversationUnion{ 227 - OfString: openai.String(conversationID), 228 - }, 229 - Text: scheme(c.schema), 238 + Text: scheme(c.schema), 230 239 } 231 240 resp, err := client.Responses.New(ctx, params) 232 241 if err != nil { 233 242 return nil, fmt.Errorf("failed to regenerate recipes: %w", err) 234 243 } 235 244 236 - if resp.Conversation.ID != conversationID { 237 - return nil, fmt.Errorf("conversation ID mismatch in regeneration response") 238 - } 239 - 240 245 return responseToShoppingList(ctx, resp) 241 246 } 242 247 243 - func (c *client) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 248 + func (c *client) AskQuestion(ctx context.Context, question string, previousResponseID string) (*QuestionResponse, error) { 244 249 question = strings.TrimSpace(question) 245 250 if question == "" { 246 - return "", fmt.Errorf("question is required") 251 + return nil, fmt.Errorf("question is required") 247 252 } 248 - if conversationID == "" { 249 - return "", fmt.Errorf("conversation ID is required for questions") 253 + if previousResponseID == "" { 254 + return nil, fmt.Errorf("response ID is required for questions") 250 255 } 251 256 client := openai.NewClient(option.WithAPIKey(c.apiKey)) 252 257 253 258 params := responses.ResponseNewParams{ 254 - Model: c.model, 255 - Instructions: openai.String("Answer the user's question about the recipe in plain text. Be concise and do not regenerate the full recipe or output JSON."), 259 + Model: c.model, 260 + PreviousResponseID: openai.String(previousResponseID), 261 + Instructions: openai.String("Answer the user's question about the recipe in plain text. Be concise and do not regenerate the full recipe or output JSON."), 256 262 Input: responses.ResponseNewParamsInputUnion{ 257 263 OfInputItemList: []responses.ResponseInputItemUnionParam{user(question)}, 258 264 }, 259 265 Store: openai.Bool(true), 260 - Conversation: responses.ResponseNewParamsConversationUnion{ 261 - OfString: openai.String(conversationID), 262 - }, 263 266 } 264 267 resp, err := client.Responses.New(ctx, params) 265 268 if err != nil { 266 - return "", fmt.Errorf("failed to answer question: %w", err) 269 + return nil, fmt.Errorf("failed to answer question: %w", err) 267 270 } 268 271 answer := strings.TrimSpace(resp.OutputText()) 269 272 if answer == "" { 270 - return "", fmt.Errorf("empty response from model") 273 + return nil, fmt.Errorf("empty response from model") 274 + } 275 + if strings.TrimSpace(resp.ID) == "" { 276 + return nil, fmt.Errorf("failed to get response ID for question") 271 277 } 272 - return answer, nil 278 + return &QuestionResponse{ 279 + Answer: answer, 280 + ResponseID: resp.ID, 281 + }, nil 273 282 } 274 283 275 284 func (c *client) GenerateRecipeImage(ctx context.Context, recipe Recipe) (*GeneratedImage, error) { ··· 370 379 } 371 380 372 381 client := openai.NewClient(option.WithAPIKey(c.apiKey)) 373 - convo, err := client.Conversations.New(ctx, conversations.ConversationNewParams{}) 374 - if err != nil { 375 - return nil, fmt.Errorf("failed to create conversation: %w", err) 376 - } 377 - 378 382 params := responses.ResponseNewParams{ 379 383 Model: c.model, 380 384 Instructions: openai.String(systemMessage), ··· 383 387 OfInputItemList: messages, 384 388 }, 385 389 Store: openai.Bool(true), 386 - Conversation: responses.ResponseNewParamsConversationUnion{ 387 - OfConversationObject: &responses.ResponseConversationParam{ 388 - ID: convo.ID, 389 - }, 390 - }, 391 - Text: scheme(c.schema), 390 + Text: scheme(c.schema), 392 391 } 393 392 // should we stream. Can we pass past generation. 394 393
+26 -23
internal/recipes/generator.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/base64" 6 5 "fmt" 7 - "hash/fnv" 8 - "io" 9 6 "log/slog" 10 7 "slices" 11 8 "strings" ··· 23 20 24 21 type aiClient interface { 25 22 GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) 26 - Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) 27 - AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 23 + Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error) 24 + AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) 28 25 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 29 26 PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error) 30 27 } ··· 100 97 hash := p.Hash() 101 98 start := time.Now() 102 99 103 - if p.ConversationID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) { 104 - slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID) 100 + // if we have a response id one of the three should be true? Or did they just not care and hit try again? 101 + if p.ResponseID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) { 102 + slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "response_id", p.ResponseID) 105 103 instructions := regenerateInstructions(p) 106 104 107 - shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ConversationID) 105 + shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ResponseID) 108 106 if err != nil { 109 107 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) 110 108 } 109 + // would prefer to do this deepe down in client 110 + for i := range shoppingList.Recipes { 111 + shoppingList.Recipes[i].OriginHash = hash 112 + } 113 + 111 114 shoppingList, err = g.critiqueAndMaybeRetry(ctx, hash, shoppingList) 112 115 if err != nil { 113 116 return nil, err 114 117 } 118 + 115 119 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) 116 120 117 121 slog.InfoContext(ctx, "regenerated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) ··· 130 134 if err != nil { 131 135 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) 132 136 } 137 + // would prefer to do this deepe down in client like response id but have to pass in the hash 138 + for i := range shoppingList.Recipes { 139 + shoppingList.Recipes[i].OriginHash = hash 140 + } 133 141 134 142 shoppingList, err = g.critiqueAndMaybeRetry(ctx, hash, shoppingList) 135 143 if err != nil { 136 144 return nil, err 137 145 } 138 146 139 - p.ConversationID = shoppingList.ConversationID 147 + p.ResponseID = shoppingList.ResponseID 140 148 slog.InfoContext(ctx, "generated chat", "location", p.String(), "duration", time.Since(start), "hash", hash) 141 149 return shoppingList, nil 142 150 } 143 151 144 - func (g *generatorService) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 145 - return g.aiClient.AskQuestion(ctx, question, conversationID) 152 + // generator not prociding a lot of value here. Should sever just hold an ai client? 153 + func (g *generatorService) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 154 + return g.aiClient.AskQuestion(ctx, question, previousResponseID) 146 155 } 147 156 148 157 func (g *generatorService) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { ··· 160 169 return "empty" 161 170 } 162 171 return *s 163 - } 164 - 165 - func wineIngredientsCacheKey(style, location string, date time.Time) string { 166 - normalizedStyle := strings.ToLower(strings.TrimSpace(style)) 167 - fnv := fnv.New64a() 168 - lo.Must(io.WriteString(fnv, location)) 169 - lo.Must(io.WriteString(fnv, date.Format("2006-01-02"))) 170 - lo.Must(io.WriteString(fnv, normalizedStyle)) 171 - return "wines/" + base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 172 172 } 173 173 174 174 func newlySaved(saved []ai.Recipe, priorSavedHashes []string) []string { ··· 225 225 garbageRecipes := lo.Map(garbage, func(r critique.Result, _ int) ai.Recipe { return *r.Recipe }) 226 226 g.writeStatus(ctx, hash, titles("Making adjustments to these recipes: ", garbageRecipes)) 227 227 228 - if strings.TrimSpace(shoppingList.ConversationID) == "" { 229 - return nil, fmt.Errorf("conversation ID is required for critique retry") 228 + if strings.TrimSpace(shoppingList.ResponseID) == "" { 229 + return nil, fmt.Errorf("response ID is required for critique retry") 230 230 } 231 231 232 232 // we could also just give all feedback back if any are below score 233 - shoppingList, err := g.aiClient.Regenerate(ctx, critique.RetryInstructions(garbage), shoppingList.ConversationID) 233 + shoppingList, err := g.aiClient.Regenerate(ctx, critique.RetryInstructions(garbage), shoppingList.ResponseID) 234 234 if err != nil { 235 235 return nil, fmt.Errorf("failed to regenerate recipes from critique feedback: %w", err) 236 + } 237 + for i := range shoppingList.Recipes { 238 + shoppingList.Recipes[i].OriginHash = hash 236 239 } 237 240 newRecipes := shoppingList.Recipes 238 241 linkToParents(garbage, recipePtrs(newRecipes))
+64 -64
internal/recipes/generator_test.go
··· 26 26 } 27 27 28 28 type captureRegenerateAIClient struct { 29 - instructions []string 30 - conversationID string 31 - shoppingList *ai.ShoppingList 29 + instructions []string 30 + responseID string 31 + shoppingList *ai.ShoppingList 32 32 } 33 33 34 34 type captureGenerateAIClient struct { ··· 41 41 generateInstructions [][]string 42 42 regenerateCalls int 43 43 regenerateInstructions [][]string 44 - regenerateConversation []string 44 + regenerateResponseIDs []string 45 45 generateResponses []*ai.ShoppingList 46 46 regenerateResponses []*ai.ShoppingList 47 47 } ··· 63 63 panic("unexpected call to GenerateRecipes") 64 64 } 65 65 66 - func (c *captureWineQuestionAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 66 + func (c *captureWineQuestionAIClient) Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error) { 67 67 panic("unexpected call to Regenerate") 68 68 } 69 69 70 - func (c *captureWineQuestionAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 70 + func (c *captureWineQuestionAIClient) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 71 71 c.question = question 72 - return c.answer, nil 72 + return &ai.QuestionResponse{Answer: c.answer, ResponseID: "resp-question"}, nil 73 73 } 74 74 75 75 func (c *captureWineQuestionAIClient) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { ··· 95 95 panic("unexpected call to GenerateRecipes") 96 96 } 97 97 98 - func (c *captureRegenerateAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 98 + func (c *captureRegenerateAIClient) Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error) { 99 99 c.instructions = append([]string(nil), newinstructions...) 100 - c.conversationID = conversationID 100 + c.responseID = previousResponseID 101 101 if c.shoppingList != nil { 102 102 return c.shoppingList, nil 103 103 } 104 104 return &ai.ShoppingList{}, nil 105 105 } 106 106 107 - func (c *captureRegenerateAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 107 + func (c *captureRegenerateAIClient) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 108 108 panic("unexpected call to AskQuestion") 109 109 } 110 110 ··· 127 127 return &ai.ShoppingList{}, nil 128 128 } 129 129 130 - func (c *captureGenerateAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 130 + func (c *captureGenerateAIClient) Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error) { 131 131 panic("unexpected call to Regenerate") 132 132 } 133 133 134 - func (c *captureGenerateAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 134 + func (c *captureGenerateAIClient) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 135 135 panic("unexpected call to AskQuestion") 136 136 } 137 137 ··· 161 161 return resp, nil 162 162 } 163 163 164 - func (c *sequenceAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 164 + func (c *sequenceAIClient) Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error) { 165 165 c.mu.Lock() 166 166 defer c.mu.Unlock() 167 167 168 168 c.regenerateCalls++ 169 169 c.regenerateInstructions = append(c.regenerateInstructions, append([]string(nil), newinstructions...)) 170 - c.regenerateConversation = append(c.regenerateConversation, conversationID) 170 + c.regenerateResponseIDs = append(c.regenerateResponseIDs, previousResponseID) 171 171 if len(c.regenerateResponses) == 0 { 172 172 return &ai.ShoppingList{}, nil 173 173 } ··· 176 176 return resp, nil 177 177 } 178 178 179 - func (c *sequenceAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 179 + func (c *sequenceAIClient) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 180 180 panic("unexpected call to AskQuestion") 181 181 } 182 182 ··· 359 359 360 360 aiStub := &captureRegenerateAIClient{ 361 361 shoppingList: &ai.ShoppingList{ 362 - ConversationID: "conv-123", 363 - Recipes: []ai.Recipe{newResult}, 362 + ResponseID: "resp-123", 363 + Recipes: []ai.Recipe{newResult}, 364 364 }, 365 365 } 366 366 g := &generatorService{ ··· 368 368 } 369 369 370 370 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 371 - params.ConversationID = "conv-123" 371 + params.ResponseID = "resp-123" 372 372 params.Instructions = "make it vegetarian" 373 373 params.Saved = []ai.Recipe{alreadySaved, newlySaved} 374 374 params.Dismissed = []ai.Recipe{dismissed} ··· 387 387 if !slices.Equal(aiStub.instructions, wantInstructions) { 388 388 t.Fatalf("unexpected regenerate instructions: got %v want %v", aiStub.instructions, wantInstructions) 389 389 } 390 - if aiStub.conversationID != "conv-123" { 391 - t.Fatalf("expected conversation ID %q, got %q", "conv-123", aiStub.conversationID) 390 + if aiStub.responseID != "resp-123" { 391 + t.Fatalf("expected response ID %q, got %q", "resp-123", aiStub.responseID) 392 392 } 393 393 if got == nil || len(got.Recipes) != 3 { 394 394 t.Fatalf("expected regenerated list plus saved recipes, got %+v", got) ··· 413 413 414 414 aiStub := &captureGenerateAIClient{ 415 415 shoppingList: &ai.ShoppingList{ 416 - ConversationID: "conv-123", 417 - Recipes: generated, 416 + ResponseID: "resp-123", 417 + Recipes: generated, 418 418 }, 419 419 } 420 420 critiquer := &captureCritiqueService{} ··· 429 429 if err != nil { 430 430 t.Fatalf("GenerateRecipes returned error: %v", err) 431 431 } 432 - if got.ConversationID != "conv-123" { 433 - t.Fatalf("expected conversation id to survive, got %q", got.ConversationID) 432 + if got.ResponseID != "resp-123" { 433 + t.Fatalf("expected response id to survive, got %q", got.ResponseID) 434 434 } 435 435 if len(critiquer.recipes) != len(generated) { 436 436 t.Fatalf("expected %d critiques, got %d", len(generated), len(critiquer.recipes)) ··· 450 450 451 451 critiquer := &captureCritiqueService{} 452 452 g := &generatorService{ 453 - aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ConversationID: "conv-123", Recipes: []ai.Recipe{newResult}}}, 453 + aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ResponseID: "resp-123", Recipes: []ai.Recipe{newResult}}}, 454 454 critiquer: critiquer, 455 455 statusWriter: noopstatuswriter{}, 456 456 } 457 457 458 458 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 459 - params.ConversationID = "conv-123" 459 + params.ResponseID = "resp-123" 460 460 params.Saved = []ai.Recipe{alreadySaved} 461 461 462 462 got, err := g.GenerateRecipes(t.Context(), params) ··· 484 484 485 485 aiStub := &sequenceAIClient{ 486 486 generateResponses: []*ai.ShoppingList{{ 487 - ConversationID: "conv-initial", 488 - Recipes: []ai.Recipe{initial}, 487 + ResponseID: "resp-initial", 488 + Recipes: []ai.Recipe{initial}, 489 489 }}, 490 490 regenerateResponses: []*ai.ShoppingList{{ 491 - ConversationID: "conv-retried", 492 - Recipes: []ai.Recipe{retried}, 491 + ResponseID: "resp-retried", 492 + Recipes: []ai.Recipe{retried}, 493 493 }}, 494 494 } 495 495 critiquer := &captureCritiqueService{ ··· 534 534 if got == nil || len(got.Recipes) != 1 || got.Recipes[0].Title != "Better Dinner" { 535 535 t.Fatalf("expected retried shopping list, got %+v", got) 536 536 } 537 - if got.ConversationID != "conv-retried" { 538 - t.Fatalf("expected final conversation ID %q, got %q", "conv-retried", got.ConversationID) 537 + if got.ResponseID != "resp-retried" { 538 + t.Fatalf("expected final response ID %q, got %q", "resp-retried", got.ResponseID) 539 539 } 540 540 if aiStub.regenerateCalls != 1 { 541 541 t.Fatalf("expected one critique-driven regenerate call, got %d", aiStub.regenerateCalls) ··· 547 547 if got := aiStub.regenerateInstructions[0]; !slices.Equal(got, wantInstructions) { 548 548 t.Fatalf("unexpected critique retry instructions: got %v want %v", got, wantInstructions) 549 549 } 550 - if got := aiStub.regenerateConversation; !slices.Equal(got, []string{"conv-initial"}) { 551 - t.Fatalf("unexpected critique retry conversation IDs: got %v", got) 550 + if got := aiStub.regenerateResponseIDs; !slices.Equal(got, []string{"resp-initial"}) { 551 + t.Fatalf("unexpected critique retry response IDs: got %v", got) 552 552 } 553 553 if len(critiquer.recipes) != 2 { 554 554 t.Fatalf("expected two critique passes, got %d", len(critiquer.recipes)) ··· 572 572 573 573 aiStub := &sequenceAIClient{ 574 574 generateResponses: []*ai.ShoppingList{{ 575 - ConversationID: "conv-initial", 576 - Recipes: []ai.Recipe{weak, good}, 575 + ResponseID: "resp-initial", 576 + Recipes: []ai.Recipe{weak, good}, 577 577 }}, 578 578 regenerateResponses: []*ai.ShoppingList{{ 579 - ConversationID: "conv-retried", 580 - Recipes: []ai.Recipe{retried}, 579 + ResponseID: "resp-retried", 580 + Recipes: []ai.Recipe{retried}, 581 581 }}, 582 582 } 583 583 critiquer := &captureCritiqueService{ ··· 638 638 639 639 aiStub := &sequenceAIClient{ 640 640 generateResponses: []*ai.ShoppingList{{ 641 - ConversationID: "conv-stable", 642 - Recipes: []ai.Recipe{steady}, 641 + ResponseID: "resp-stable", 642 + Recipes: []ai.Recipe{steady}, 643 643 }}, 644 644 } 645 645 g := &generatorService{ ··· 683 683 statuses := &statusCounter{} 684 684 g := &generatorService{ 685 685 staples: &cachedStaplesService{cache: io}, 686 - aiClient: &sequenceAIClient{generateResponses: []*ai.ShoppingList{{ConversationID: "conv-stable", Recipes: []ai.Recipe{steady}}}}, 686 + aiClient: &sequenceAIClient{generateResponses: []*ai.ShoppingList{{ResponseID: "resp-stable", Recipes: []ai.Recipe{steady}}}}, 687 687 critiquer: &captureCritiqueService{}, 688 688 statusWriter: statuses, 689 689 } ··· 701 701 aiStub := &sequenceAIClient{ 702 702 regenerateResponses: []*ai.ShoppingList{ 703 703 { 704 - ConversationID: "conv-first-pass", 705 - Recipes: []ai.Recipe{initial}, 704 + ResponseID: "resp-first-pass", 705 + Recipes: []ai.Recipe{initial}, 706 706 }, 707 707 { 708 - ConversationID: "conv-second-pass", 709 - Recipes: []ai.Recipe{retried}, 708 + ResponseID: "resp-second-pass", 709 + Recipes: []ai.Recipe{retried}, 710 710 }, 711 711 }, 712 712 } ··· 744 744 } 745 745 746 746 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 747 - params.ConversationID = "conv-original" 747 + params.ResponseID = "resp-original" 748 748 params.Instructions = "make it vegetarian" 749 749 params.Saved = []ai.Recipe{alreadySaved} 750 750 ··· 758 758 if got.Recipes[0].Title != "Ready Dinner" || got.Recipes[1].Title != "Already Saved" { 759 759 t.Fatalf("unexpected recipe order after critique retry: %+v", got.Recipes) 760 760 } 761 + if got.ResponseID != "resp-second-pass" { 762 + t.Fatalf("expected final response ID %q, got %q", "resp-second-pass", got.ResponseID) 763 + } 761 764 if got.Recipes[0].ParentHash != initial.ComputeHash() { 762 765 t.Fatalf("expected retried recipe to point to the first-pass recipe, got %+v", got.Recipes[0]) 763 766 } 764 - if got.ConversationID != "conv-second-pass" { 765 - t.Fatalf("expected final conversation ID %q, got %q", "conv-second-pass", got.ConversationID) 766 - } 767 767 if aiStub.regenerateCalls != 2 { 768 768 t.Fatalf("expected initial regenerate plus one critique retry, got %d calls", aiStub.regenerateCalls) 769 769 } 770 - if got := aiStub.regenerateConversation; !slices.Equal(got, []string{"conv-original", "conv-first-pass"}) { 771 - t.Fatalf("unexpected regenerate conversation IDs: got %v", got) 770 + if got := aiStub.regenerateResponseIDs; !slices.Equal(got, []string{"resp-original", "resp-first-pass"}) { 771 + t.Fatalf("unexpected regenerate response IDs: got %v", got) 772 772 } 773 773 wantRetryInstructions := []string{ 774 774 "Revise and return exactly 1 recipes as replacements for the low-scoring recipes listed below. Description should focus on selling the dish not these corrections", ··· 786 786 aiStub := &sequenceAIClient{ 787 787 regenerateResponses: []*ai.ShoppingList{ 788 788 { 789 - ConversationID: "conv-first-pass", 790 - Recipes: []ai.Recipe{firstPass}, 789 + ResponseID: "resp-first-pass", 790 + Recipes: []ai.Recipe{firstPass}, 791 791 }, 792 792 { 793 - ConversationID: "conv-second-pass", 794 - Recipes: []ai.Recipe{retried}, 793 + ResponseID: "resp-second-pass", 794 + Recipes: []ai.Recipe{retried}, 795 795 }, 796 796 }, 797 797 } ··· 824 824 } 825 825 826 826 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 827 - params.ConversationID = "conv-original" 827 + params.ResponseID = "resp-original" 828 828 params.Instructions = "make it fresher" 829 829 got, err := g.GenerateRecipes(t.Context(), params) 830 830 if err != nil { ··· 853 853 854 854 aiStub := &sequenceAIClient{ 855 855 generateResponses: []*ai.ShoppingList{{ 856 - ConversationID: "conv-initial", 857 - Recipes: []ai.Recipe{firstPassChicken, firstPassTacos}, 856 + ResponseID: "resp-initial", 857 + Recipes: []ai.Recipe{firstPassChicken, firstPassTacos}, 858 858 }}, 859 859 regenerateResponses: []*ai.ShoppingList{{ 860 - ConversationID: "conv-retried", 861 - Recipes: []ai.Recipe{retriedTacos, retriedChicken}, 860 + ResponseID: "resp-retried", 861 + Recipes: []ai.Recipe{retriedTacos, retriedChicken}, 862 862 }}, 863 863 } 864 864 critiquer := &captureCritiqueService{ ··· 920 920 921 921 aiStub := &sequenceAIClient{ 922 922 generateResponses: []*ai.ShoppingList{{ 923 - ConversationID: "conv-one", 924 - Recipes: []ai.Recipe{initial}, 923 + ResponseID: "resp-one", 924 + Recipes: []ai.Recipe{initial}, 925 925 }}, 926 926 regenerateResponses: []*ai.ShoppingList{{ 927 - ConversationID: "conv-two", 928 - Recipes: []ai.Recipe{retried}, 927 + ResponseID: "resp-two", 928 + Recipes: []ai.Recipe{retried}, 929 929 }}, 930 930 } 931 931 critiquer := &captureCritiqueService{
+28 -8
internal/recipes/html.go
··· 100 100 Recipes []shoppingRecipeView 101 101 ShoppingList []ai.Ingredient 102 102 HasSavedRecipes bool 103 - ConversationID string 104 103 Style seasons.Style 105 104 ServerSignedIn bool 106 105 }{ ··· 113 112 Recipes: recipeViews, 114 113 ShoppingList: shoppingList, 115 114 HasSavedRecipes: len(p.Saved) > 0, 116 - ConversationID: l.ConversationID, 117 115 Style: seasons.GetCurrentStyle(), 118 116 ServerSignedIn: signedIn, 119 117 } ··· 125 123 } 126 124 127 125 // FormatRecipeHTML renders a single recipe view with a browser session id for analytics. 128 - func FormatRecipeHTML(ctx context.Context, p *generatorParams, recipe ai.Recipe, signedIn bool, critiqueScore *int, hasRecipeImage bool, thread []RecipeThreadEntry, fb feedback.Feedback, wineRecommendation *ai.WineSelection, writer http.ResponseWriter) { 126 + func FormatRecipeHTML(ctx context.Context, p *generatorParams, recipe ai.Recipe, signedIn bool, 127 + critiqueScore *int, hasRecipeImage bool, thread []RecipeThreadEntry, 128 + fb feedback.Feedback, wineRecommendation *ai.WineSelection, writer http.ResponseWriter, 129 + ) { 129 130 slices.SortFunc(thread, func(i, j RecipeThreadEntry) int { 130 131 return j.CreatedAt.Compare(i.CreatedAt) 131 132 }) 132 133 recipeHash := recipe.ComputeHash() 134 + activeResponseID := recipe.ResponseID 135 + if threadResponseID := latestThreadResponseID(thread); threadResponseID != "" { 136 + activeResponseID = threadResponseID 137 + } 133 138 data := struct { 134 139 Location locations.Location 135 140 Date string ··· 138 143 Recipe ai.Recipe 139 144 DisplayIngredients []ai.Ingredient 140 145 OriginHash string 141 - ConversationID string 146 + ResponseID string 142 147 WineRecommendation *ai.WineSelection 143 148 Thread []RecipeThreadEntry 144 149 Feedback feedback.Feedback ··· 156 161 Recipe: recipe, 157 162 DisplayIngredients: ingredientsForDisplay(recipe.Ingredients, wineRecommendation), 158 163 OriginHash: recipe.OriginHash, 159 - ConversationID: p.ConversationID, 164 + ResponseID: activeResponseID, 160 165 WineRecommendation: wineRecommendation, 161 166 Thread: thread, 162 167 Feedback: fb, ··· 217 222 } 218 223 219 224 // FormatRecipeThreadHTML renders the question thread fragment for HTMX swaps. 220 - func FormatRecipeThreadHTML(thread []RecipeThreadEntry, signedIn bool, conversationID string, writer http.ResponseWriter) { 225 + func FormatRecipeThreadHTML(thread []RecipeThreadEntry, signedIn bool, responseID string, writer http.ResponseWriter) { 221 226 // memory waste because we alwways resort? 222 227 slices.SortFunc(thread, func(i, j RecipeThreadEntry) int { 223 228 return j.CreatedAt.Compare(i.CreatedAt) 224 229 }) 225 230 data := struct { 226 - ConversationID string 231 + ResponseID string 227 232 Thread []RecipeThreadEntry 228 233 ServerSignedIn bool 229 234 }{ 230 - ConversationID: conversationID, 235 + ResponseID: responseID, 231 236 Thread: thread, 232 237 ServerSignedIn: signedIn, 233 238 } ··· 299 304 } 300 305 301 306 return templates.ShoppingList.ExecuteTemplate(writer, "shopping_finalize_controls_response", data) 307 + } 308 + 309 + func latestThreadResponseID(thread []RecipeThreadEntry) string { 310 + if len(thread) == 0 { 311 + return "" 312 + } 313 + slices.SortFunc(thread, func(i, j RecipeThreadEntry) int { 314 + return j.CreatedAt.Compare(i.CreatedAt) 315 + }) 316 + for _, entry := range thread { 317 + if responseID := strings.TrimSpace(entry.ResponseID); responseID != "" { 318 + return responseID 319 + } 320 + } 321 + return "" 302 322 } 303 323 304 324 // drops clarity, instructions and most of shoppinglist
+14 -10
internal/recipes/html_test.go
··· 216 216 func TestFormatRecipeHTML_NoFinalizeOrRegenerate(t *testing.T) { 217 217 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 218 218 p := DefaultParams(&loc, time.Now()) 219 - p.ConversationID = "convo123" 219 + recipe := list.Recipes[0] 220 + recipe.ResponseID = "resp-123" 220 221 w := httptest.NewRecorder() 221 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 222 + FormatRecipeHTML(t.Context(), p, recipe, true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 222 223 html := assertHTTPSuccess(t, w) 223 224 224 225 isValidHTML(t, html) ··· 294 295 func TestFormatRecipeHTML_HidesQuestionInputWhenSignedOut(t *testing.T) { 295 296 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 296 297 p := DefaultParams(&loc, time.Now()) 297 - p.ConversationID = "convo123" 298 + recipe := list.Recipes[0] 299 + recipe.ResponseID = "resp-123" 298 300 w := httptest.NewRecorder() 299 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], false, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 301 + FormatRecipeHTML(t.Context(), p, recipe, false, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 300 302 html := assertHTTPSuccess(t, w) 301 303 302 304 isValidHTML(t, html) ··· 318 320 func TestFormatRecipeHTML_ShowsRecipeCritiqueScore(t *testing.T) { 319 321 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 320 322 p := DefaultParams(&loc, time.Now()) 321 - p.ConversationID = "convo123" 323 + recipe := list.Recipes[0] 324 + recipe.ResponseID = "resp-123" 322 325 w := httptest.NewRecorder() 323 326 score := 8 324 327 325 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, &score, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 328 + FormatRecipeHTML(t.Context(), p, recipe, true, &score, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 326 329 html := assertHTTPSuccess(t, w) 327 330 328 331 isValidHTML(t, html) ··· 392 395 func TestFormatRecipeHTML_RendersCachedWineRecommendation(t *testing.T) { 393 396 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 394 397 p := DefaultParams(&loc, time.Now()) 395 - p.ConversationID = "convo123" 398 + recipe := list.Recipes[0] 399 + recipe.ResponseID = "resp-123" 396 400 w := httptest.NewRecorder() 397 - FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, &ai.WineSelection{ 401 + FormatRecipeHTML(t.Context(), p, recipe, true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, &ai.WineSelection{ 398 402 Wines: []ai.Ingredient{ 399 403 {Name: "Oregon Pinot Noir", Price: "$14.99"}, 400 404 {Name: "Backup Chardonnay", Price: "$11.99"}, ··· 425 429 func TestFormatRecipeHTML_AllowsIngredientWithoutPrice(t *testing.T) { 426 430 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 427 431 p := DefaultParams(&loc, time.Now()) 428 - p.ConversationID = "convo123" 429 432 w := httptest.NewRecorder() 430 433 recipe := ai.Recipe{ 431 434 Title: "Market Greens", ··· 438 441 Instructions: []string{"Wash and plate."}, 439 442 Health: "Light", 440 443 DrinkPairing: "Sparkling water", 444 + ResponseID: "resp-123", 441 445 } 442 446 443 447 FormatRecipeHTML(t.Context(), p, recipe, true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) ··· 494 498 func TestFormatRecipeHTML_RendersRecipeImage(t *testing.T) { 495 499 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 496 500 p := DefaultParams(&loc, time.Now()) 497 - p.ConversationID = "convo123" 498 501 w := httptest.NewRecorder() 499 502 recipe := list.Recipes[0] 503 + recipe.ResponseID = "resp-123" 500 504 recipeHash := recipe.ComputeHash() 501 505 502 506 FormatRecipeHTML(t.Context(), p, recipe, true, nil, true, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w)
-7
internal/recipes/io.go
··· 158 158 } 159 159 160 160 func (rio recipeio) SaveShoppingList(ctx context.Context, shoppingList *ai.ShoppingList, hash string) error { 161 - for i := range shoppingList.Recipes { 162 - shoppingList.Recipes[i].OriginHash = hash 163 - } 164 - for i := range shoppingList.Discarded { 165 - shoppingList.Discarded[i].OriginHash = hash 166 - } 167 - 168 161 // Save each recipe separately by its hash 169 162 if err := rio.saveRecipes(ctx, append(shoppingList.Recipes, shoppingList.Discarded...)); err != nil { 170 163 return err
+9 -6
internal/recipes/io_test.go
··· 81 81 82 82 hash := "test-hash" 83 83 list := &ai.ShoppingList{ 84 - ConversationID: "conversation-123", 84 + ResponseID: "resp-123", 85 85 Recipes: []ai.Recipe{ 86 86 { 87 + OriginHash: hash, 87 88 Title: "One Pan Chicken", 88 89 Description: "Simple weeknight meal", 89 90 Ingredients: []ai.Ingredient{{Name: "Chicken", Quantity: "1 lb", Price: "5.99"}}, ··· 109 110 if err != nil { 110 111 t.Fatalf("FromCache failed: %v", err) 111 112 } 112 - if got.ConversationID != list.ConversationID { 113 - t.Fatalf("expected conversation id %q, got %q", list.ConversationID, got.ConversationID) 113 + if got.ResponseID != list.ResponseID { 114 + t.Fatalf("expected response id %q, got %q", list.ResponseID, got.ResponseID) 114 115 } 115 116 } 116 117 ··· 120 121 rio := IO(cacheStore) 121 122 122 123 kept := ai.Recipe{ 124 + OriginHash: "test-hash", 123 125 Title: "One Pan Chicken", 124 126 Description: "Simple weeknight meal", 125 127 Ingredients: []ai.Ingredient{{Name: "Chicken", Quantity: "1 lb", Price: "5.99"}}, ··· 128 130 DrinkPairing: "Chardonnay", 129 131 } 130 132 discarded := ai.Recipe{ 133 + OriginHash: "test-hash", 131 134 Title: "Mushy Pasta", 132 135 Description: "Too vague to keep", 133 136 Ingredients: []ai.Ingredient{{Name: "Pasta", Quantity: "1 lb", Price: "1.99"}}, ··· 137 140 } 138 141 hash := "test-hash" 139 142 list := &ai.ShoppingList{ 140 - ConversationID: "conversation-123", 141 - Recipes: []ai.Recipe{kept}, 142 - Discarded: []ai.Recipe{discarded}, 143 + ResponseID: "resp-123", 144 + Recipes: []ai.Recipe{kept}, 145 + Discarded: []ai.Recipe{discarded}, 143 146 } 144 147 145 148 if err := rio.SaveShoppingList(t.Context(), list, hash); err != nil {
+12 -6
internal/recipes/mock.go
··· 364 364 } 365 365 366 366 func (m mock) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { 367 - id := p.ConversationID 367 + id := p.ResponseID 368 368 if id == "" { 369 369 id = uuid.NewString() 370 370 } 371 + originHash := p.Hash() 371 372 // fake like we're taking time to call an LLM so we get the spinner. 372 373 time.Sleep(100 * time.Millisecond) 373 374 ··· 387 388 } 388 389 mr := mockRecipes[idx] 389 390 if _, found := seen[mr.ComputeHash()]; !found { 391 + mr.OriginHash = originHash 392 + mr.ResponseID = id 390 393 391 394 slog.InfoContext(ctx, "adding", "title", mr.Title) 392 395 selectedRecipes = append(selectedRecipes, mr) ··· 401 404 } 402 405 403 406 return &ai.ShoppingList{ 404 - ConversationID: id, 405 - Recipes: selectedRecipes, 407 + ResponseID: id, 408 + Recipes: selectedRecipes, 406 409 }, nil 407 410 } 408 411 409 - func (m mock) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 410 - _ = conversationID 411 - return fmt.Sprintf("Mock answer: %s", question), nil 412 + func (m mock) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 413 + _ = previousResponseID 414 + return &ai.QuestionResponse{ 415 + Answer: fmt.Sprintf("Mock answer: %s", question), 416 + ResponseID: uuid.NewString(), 417 + }, nil 412 418 } 413 419 414 420 func (m mock) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) {
+3 -3
internal/recipes/params.go
··· 36 36 Directive string `json:"directive,omitempty"` // this is the new one that will be used. Can remove GenerationPrompt after a while. 37 37 LastRecipes []string `json:"-"` // this doesn't get populated until after save. 38 38 // UserID string `json:"user_id,omitempty"` 39 - ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes? 39 + ResponseID string `json:"response_id,omitempty"` 40 40 // TODO Both should just be title and hash instead of full ai.Recipe 41 41 Saved []ai.Recipe `json:"saved_recipes,omitempty"` 42 42 Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"` ··· 63 63 } 64 64 65 65 // Hash this is how we find shoppinglists and params 66 - // intentionally not including ConversationID to preserve old hashes 66 + // intentionally not including ResponseID to preserve old hashes 67 67 func (g *generatorParams) Hash() string { 68 68 fnv := fnv.New64a() 69 69 lo.Must(io.WriteString(fnv, g.Location.ID)) ··· 130 130 131 131 p := DefaultParams(l, date) 132 132 p.Instructions = r.URL.Query().Get("instructions") 133 - p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 133 + p.ResponseID = strings.TrimSpace(r.URL.Query().Get("response_id")) 134 134 135 135 return p, nil 136 136 }
+4 -4
internal/recipes/params_test.go
··· 65 65 } 66 66 } 67 67 68 - func TestParseQueryArgs_PopulatesConversationID(t *testing.T) { 68 + func TestParseQueryArgs_PopulatesResponseID(t *testing.T) { 69 69 location := &locations.Location{ 70 70 ID: "store-1", 71 71 Name: "Test Store", 72 72 ZipCode: "10001", 73 73 } 74 74 75 - req := httptest.NewRequest("GET", "/recipes?location=store-1&conversation_id=conv-123", nil) 75 + req := httptest.NewRequest("GET", "/recipes?location=store-1&response_id=resp-123", nil) 76 76 p, err := ParseQueryArgs(context.Background(), req, staticLocationLookup{location: location}) 77 77 if err != nil { 78 78 t.Fatalf("ParseQueryArgs returned error: %v", err) 79 79 } 80 80 81 - if got, want := p.ConversationID, "conv-123"; got != want { 82 - t.Fatalf("expected conversation id %q, got %q", want, got) 81 + if got, want := p.ResponseID, "resp-123"; got != want { 82 + t.Fatalf("expected response id %q, got %q", want, got) 83 83 } 84 84 } 85 85
+15 -26
internal/recipes/server.go
··· 75 75 76 76 type generator interface { 77 77 GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) 78 - AskQuestion(ctx context.Context, question string, conversationID string) (string, error) 78 + AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) 79 79 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) 80 80 PickAWine(ctx context.Context, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) 81 81 } ··· 237 237 return 238 238 } 239 239 240 - if p.ConversationID == "" { 241 - if slist, err := s.FromCache(ctx, recipe.OriginHash); err == nil { 242 - p.ConversationID = slist.ConversationID 243 - } else if !errors.Is(err, cache.ErrNotFound) { 244 - slog.ErrorContext(ctx, "failed to load conversation id", "hash", recipe.OriginHash, "error", err) 245 - } 246 - } 247 - 248 - slog.InfoContext(ctx, "serving shared recipe by hash", "hash", hash, "signedIn", signedIn) 240 + slog.InfoContext(ctx, "serving recipe by hash", "hash", hash, "signedIn", signedIn) 249 241 FormatRecipeHTML(ctx, p, *recipe, signedIn, critiqueScore, hasRecipeImage, thread, feedback, wineRecommendation, w) 250 242 } 251 243 ··· 380 372 questionForModel = fmt.Sprintf("Regarding %s: %s", recipeTitle, question) 381 373 } 382 374 383 - // TODO: conversation id is user-provided form input. 384 - // Also still curious if we should fork conversation per recipe 385 - conversationID := strings.TrimSpace(r.FormValue("conversation_id")) 386 - if conversationID == "" { 387 - slog.ErrorContext(ctx, "failed to load conversation id", "hash", hash) 388 - http.Error(w, "conversation id not found", http.StatusInternalServerError) 375 + responseID := strings.TrimSpace(r.FormValue("response_id")) 376 + if responseID == "" { 377 + slog.ErrorContext(ctx, "failed to load response id", "hash", hash) 378 + http.Error(w, "lost context on this recipe", http.StatusInternalServerError) 389 379 return 390 380 } 391 381 ··· 393 383 // can't use request context because it will be canceled when request finishes but we want to finish processing question and save it to cache. 394 384 ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 45*time.Second) 395 385 defer cancel() 396 - answer, err := s.generator.AskQuestion(ctx, questionForModel, conversationID) 386 + answer, err := s.generator.AskQuestion(ctx, questionForModel, responseID) 397 387 if err != nil { 398 388 slog.ErrorContext(ctx, "failed to answer question", "hash", hash, "error", err) 399 389 http.Error(w, "failed to answer question", http.StatusInternalServerError) ··· 407 397 return 408 398 } 409 399 thread = append(thread, RecipeThreadEntry{ 410 - Question: question, 411 - Answer: answer, 412 - CreatedAt: time.Now(), 400 + Question: question, 401 + Answer: answer.Answer, 402 + ResponseID: answer.ResponseID, 403 + CreatedAt: time.Now(), 413 404 }) 414 405 if err := s.SaveThread(ctx, hash, thread); err != nil { 415 406 http.Error(w, "failed to save question", http.StatusInternalServerError) 416 407 return 417 408 } 418 409 419 - FormatRecipeThreadHTML(thread, true, conversationID, w) 410 + FormatRecipeThreadHTML(thread, true, answer.ResponseID, w) 420 411 } 421 412 422 413 func (s *server) handleWine(w http.ResponseWriter, r *http.Request) { ··· 822 813 } 823 814 824 815 shoppingList := &ai.ShoppingList{ 825 - Recipes: p.Saved, 826 - ConversationID: p.ConversationID, 816 + Recipes: p.Saved, 817 + ResponseID: p.ResponseID, 827 818 } 828 819 if err := s.SaveShoppingList(ctx, shoppingList, newHash); err != nil { 829 820 slog.ErrorContext(ctx, "failed to save finalized shopping list", "hash", newHash, "error", err) ··· 855 846 params.Instructions = instructions 856 847 params.PriorSavedHashes = lo.Map(baseParams.Saved, func(r ai.Recipe, _ int) string { return r.ComputeHash() }) 857 848 s.mergeParamsWithSelection(ctx, &params, selection, currentList.Recipes) 858 - if params.ConversationID == "" { 859 - params.ConversationID = currentList.ConversationID 860 - } 849 + params.ResponseID = currentList.ResponseID 861 850 return &params, nil 862 851 } 863 852
+85 -52
internal/recipes/server_test.go
··· 340 340 &locations.Location{ID: "70002001", Name: "Canonical Test Store"}, 341 341 time.Date(2026, 1, 25, 0, 0, 0, 0, time.UTC), 342 342 ) 343 - p.ConversationID = "conv-canonical" 343 + p.ResponseID = "resp-canonical" 344 344 canonicalHash := p.Hash() 345 345 legacyHash, ok := legacyRecipeHash(canonicalHash) 346 346 if !ok { ··· 432 432 &locations.Location{ID: "70003001", Name: "Wine Store"}, 433 433 time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), 434 434 ) 435 - p.ConversationID = "conv-wine-single" 435 + p.ResponseID = "resp-wine-single" 436 436 originHash := p.Hash() 437 437 if err := s.SaveParams(t.Context(), p); err != nil { 438 438 t.Fatalf("failed to save params: %v", err) ··· 500 500 s := newTestServer(t, withTestCache(cacheStore), withTestClerk(noSessionAuth{})) 501 501 502 502 form := url.Values{ 503 - "conversation_id": {"conv-test"}, 504 - "question": {"Can I swap the protein?"}, 503 + "response_id": {"resp-test"}, 504 + "question": {"Can I swap the protein?"}, 505 505 } 506 506 req := httptest.NewRequest(http.MethodPost, "/recipe/hash/question", strings.NewReader(form.Encode())) 507 507 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ··· 521 521 s := newTestServer(t, withTestCache(cacheStore)) 522 522 523 523 form := url.Values{ 524 - "conversation_id": {"conv-test"}, 525 - "question": {"Can I swap the protein?"}, 524 + "response_id": {"resp-test"}, 525 + "question": {"Can I swap the protein?"}, 526 526 } 527 527 req := httptest.NewRequest(http.MethodPost, "/recipe/hash/question", strings.NewReader(form.Encode())) 528 528 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ··· 560 560 return &ai.ShoppingList{}, nil 561 561 } 562 562 563 - func (c *captureKickgenerationGenerator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 563 + func (c *captureKickgenerationGenerator) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 564 564 panic("unexpected call to AskQuestion") 565 565 } 566 566 ··· 661 661 } 662 662 663 663 type captureQuestionGenerator struct { 664 - lastQuestion string 665 - lastConversationID string 666 - lastWinePick struct { 664 + lastQuestion string 665 + lastResponseID string 666 + lastWinePick struct { 667 667 recipeTitle string 668 668 date time.Time 669 669 } ··· 679 679 return &ai.ShoppingList{}, nil 680 680 } 681 681 682 - func (c *captureQuestionGenerator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 682 + func (c *captureQuestionGenerator) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) { 683 683 c.lastQuestion = question 684 - c.lastConversationID = conversationID 685 - return "Try chicken thighs at the same cook time.", nil 684 + c.lastResponseID = previousResponseID 685 + return &ai.QuestionResponse{ 686 + Answer: "Try chicken thighs at the same cook time.", 687 + ResponseID: "resp-next", 688 + }, nil 686 689 } 687 690 688 691 func (c *captureQuestionGenerator) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) { ··· 719 722 return nil 720 723 } 721 724 722 - func seedQuestionConversation(t *testing.T, s *server, conversationID string) string { 725 + func seedQuestionConversation(t *testing.T, s *server, responseID string) string { 723 726 t.Helper() 724 727 725 728 p := DefaultParams(&locations.Location{ID: "70003002", Name: "Question Test Store"}, time.Now()) ··· 737 740 recipeHash := recipe.ComputeHash() 738 741 saveRecipesForOrigin(t, s, originHash, recipe) 739 742 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 740 - Recipes: []ai.Recipe{recipe}, 741 - ConversationID: conversationID, 743 + Recipes: []ai.Recipe{recipe}, 744 + ResponseID: responseID, 742 745 }, originHash); err != nil { 743 746 t.Fatalf("failed to save shopping list: %v", err) 744 747 } ··· 752 755 withTestGenerator(&captureQuestionGenerator{}), 753 756 ) 754 757 755 - recipeHash := seedQuestionConversation(t, s, "conv-test") 758 + recipeHash := seedQuestionConversation(t, s, "resp-test") 756 759 757 760 form := url.Values{ 758 - "conversation_id": {"conv-test"}, 759 - "question": {"Can I swap the protein?"}, 761 + "response_id": {"resp-test"}, 762 + "question": {"Can I swap the protein?"}, 760 763 } 761 764 req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/question", strings.NewReader(form.Encode())) 762 765 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ··· 782 785 if !strings.Contains(body, "Try chicken thighs at the same cook time.") { 783 786 t.Fatalf("expected answer in response, got body: %s", body) 784 787 } 785 - if got, want := s.generator.(*captureQuestionGenerator).lastConversationID, "conv-test"; got != want { 786 - t.Fatalf("expected generator conversation ID %q, got %q", want, got) 788 + if got, want := s.generator.(*captureQuestionGenerator).lastResponseID, "resp-test"; got != want { 789 + t.Fatalf("expected generator response ID %q, got %q", want, got) 790 + } 791 + if !strings.Contains(body, `name="response_id" value="resp-next"`) { 792 + t.Fatalf("expected updated response id in thread fragment, got body: %s", body) 787 793 } 788 794 } 789 795 ··· 792 798 s := newTestServer(t, withTestCache(cacheStore), withTestClerk(noSessionAuth{})) 793 799 794 800 form := url.Values{ 795 - "conversation_id": {"conv-test"}, 796 - "question": {"Can I swap the protein?"}, 801 + "response_id": {"resp-test"}, 802 + "question": {"Can I swap the protein?"}, 797 803 } 798 804 req := httptest.NewRequest(http.MethodPost, "/recipe/hash/question", strings.NewReader(form.Encode())) 799 805 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ··· 819 825 withTestGenerator(g), 820 826 ) 821 827 822 - recipeHash := seedQuestionConversation(t, s, "conv-test") 828 + recipeHash := seedQuestionConversation(t, s, "resp-test") 823 829 824 830 form := url.Values{ 825 - "conversation_id": {"conv-test"}, 826 - "question": {"Can I swap the protein?"}, 827 - "recipe_title": {"BBQ Pulled Pork"}, 831 + "response_id": {"resp-test"}, 832 + "question": {"Can I swap the protein?"}, 833 + "recipe_title": {"BBQ Pulled Pork"}, 828 834 } 829 835 req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/question", strings.NewReader(form.Encode())) 830 836 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") ··· 1162 1168 Description: "Recipe to save", 1163 1169 } 1164 1170 p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1165 - p.ConversationID = "conv-123" 1171 + p.ResponseID = "resp-123" 1166 1172 originHash := p.Hash() 1167 1173 if err := s.SaveParams(t.Context(), p); err != nil { 1168 1174 t.Fatalf("failed to save params: %v", err) ··· 1170 1176 recipeHash := recipe.ComputeHash() 1171 1177 saveRecipesForOrigin(t, s, originHash, recipe) 1172 1178 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1173 - Recipes: []ai.Recipe{recipe}, 1174 - ConversationID: "conv-123", 1179 + Recipes: []ai.Recipe{recipe}, 1180 + ResponseID: "resp-123", 1175 1181 }, originHash); err != nil { 1176 1182 t.Fatalf("failed to save shopping list: %v", err) 1177 1183 } ··· 1249 1255 Description: "Recipe to save", 1250 1256 } 1251 1257 currentParams := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1252 - currentParams.ConversationID = "conv-123" 1258 + currentParams.ResponseID = "resp-123" 1253 1259 currentHash := currentParams.Hash() 1254 1260 if err := s.SaveParams(t.Context(), currentParams); err != nil { 1255 1261 t.Fatalf("failed to save params: %v", err) ··· 1257 1263 recipeHash := recipe.ComputeHash() 1258 1264 saveRecipesForOrigin(t, s, "stale-origin-hash", recipe) 1259 1265 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1260 - Recipes: []ai.Recipe{recipe}, 1261 - ConversationID: "conv-123", 1266 + Recipes: []ai.Recipe{recipe}, 1267 + ResponseID: "resp-123", 1262 1268 }, currentHash); err != nil { 1263 1269 t.Fatalf("failed to save shopping list: %v", err) 1264 1270 } ··· 1302 1308 Description: "Recipe to dismiss", 1303 1309 } 1304 1310 p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1305 - p.ConversationID = "conv-123" 1311 + p.ResponseID = "resp-123" 1306 1312 p.Saved = []ai.Recipe{recipe} 1307 1313 originHash := p.Hash() 1308 1314 if err := s.SaveParams(t.Context(), p); err != nil { ··· 1311 1317 recipeHash := recipe.ComputeHash() 1312 1318 saveRecipesForOrigin(t, s, originHash, recipe) 1313 1319 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1314 - Recipes: []ai.Recipe{recipe}, 1315 - ConversationID: "conv-123", 1320 + Recipes: []ai.Recipe{recipe}, 1321 + ResponseID: "resp-123", 1316 1322 }, originHash); err != nil { 1317 1323 t.Fatalf("failed to save shopping list: %v", err) 1318 1324 } ··· 1412 1418 Description: "Recipe to dismiss", 1413 1419 } 1414 1420 currentParams := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1415 - currentParams.ConversationID = "conv-123" 1421 + currentParams.ResponseID = "resp-123" 1416 1422 currentHash := currentParams.Hash() 1417 1423 if err := s.SaveParams(t.Context(), currentParams); err != nil { 1418 1424 t.Fatalf("failed to save params: %v", err) ··· 1420 1426 recipeHash := recipe.ComputeHash() 1421 1427 saveRecipesForOrigin(t, s, "stale-origin-hash", recipe) 1422 1428 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1423 - Recipes: []ai.Recipe{recipe}, 1424 - ConversationID: "conv-123", 1429 + Recipes: []ai.Recipe{recipe}, 1430 + ResponseID: "resp-123", 1425 1431 }, currentHash); err != nil { 1426 1432 t.Fatalf("failed to save shopping list: %v", err) 1427 1433 } ··· 1479 1485 t.Cleanup(s.Wait) 1480 1486 1481 1487 p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1482 - p.ConversationID = "conv-123" 1488 + p.ResponseID = "resp-123" 1483 1489 originHash := p.Hash() 1484 1490 if err := s.SaveParams(t.Context(), p); err != nil { 1485 1491 t.Fatalf("failed to save params: %v", err) ··· 1489 1495 dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 1490 1496 saveRecipesForOrigin(t, s, originHash, savedRecipe, dismissedRecipe) 1491 1497 shoppingList := &ai.ShoppingList{ 1492 - Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1493 - ConversationID: "conv-123", 1498 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1499 + ResponseID: "resp-123", 1494 1500 } 1495 1501 if err := s.SaveShoppingList(t.Context(), shoppingList, originHash); err != nil { 1496 1502 t.Fatalf("failed to save shopping list: %v", err) ··· 1563 1569 available := ai.Recipe{Title: "Still Available", Description: "Fresh"} 1564 1570 1565 1571 p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1566 - p.ConversationID = "conv-123" 1572 + p.ResponseID = "resp-123" 1567 1573 p.Saved = []ai.Recipe{alreadySaved} 1568 1574 originHash := p.Hash() 1569 1575 if err := s.SaveParams(t.Context(), p); err != nil { ··· 1572 1578 1573 1579 saveRecipesForOrigin(t, s, originHash, alreadySaved, newlySaved, available) 1574 1580 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1575 - Recipes: []ai.Recipe{alreadySaved, newlySaved, available}, 1576 - ConversationID: "conv-123", 1581 + Recipes: []ai.Recipe{alreadySaved, newlySaved, available}, 1582 + ResponseID: "resp-123", 1577 1583 }, originHash); err != nil { 1578 1584 t.Fatalf("failed to save shopping list: %v", err) 1579 1585 } ··· 1624 1630 ) 1625 1631 1626 1632 p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1627 - p.ConversationID = "conv-123" 1633 + p.ResponseID = "resp-123" 1628 1634 originHash := p.Hash() 1629 1635 if err := s.SaveParams(t.Context(), p); err != nil { 1630 1636 t.Fatalf("failed to save params: %v", err) ··· 1634 1640 dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 1635 1641 saveRecipesForOrigin(t, s, originHash, savedRecipe, dismissedRecipe) 1636 1642 shoppingList := &ai.ShoppingList{ 1637 - Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1638 - ConversationID: "conv-123", 1643 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1644 + ResponseID: "resp-123", 1639 1645 } 1640 1646 if err := s.SaveShoppingList(t.Context(), shoppingList, originHash); err != nil { 1641 1647 t.Fatalf("failed to save shopping list: %v", err) ··· 1695 1701 t.Fatalf("failed to save params: %v", err) 1696 1702 } 1697 1703 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1698 - Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1699 - ConversationID: "conv-1", 1704 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1705 + ResponseID: "resp-1", 1700 1706 }, originHash); err != nil { 1701 1707 t.Fatalf("failed to save shopping list: %v", err) 1702 1708 } ··· 1731 1737 t.Fatalf("failed to save params: %v", err) 1732 1738 } 1733 1739 if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1734 - Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1735 - ConversationID: "conv-1", 1740 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 1741 + ResponseID: "resp-1", 1736 1742 }, originHash); err != nil { 1737 1743 t.Fatalf("failed to save shopping list: %v", err) 1738 1744 } ··· 1754 1760 } 1755 1761 if len(updated.Dismissed) != 1 || updated.Dismissed[0].ComputeHash() != savedRecipe.ComputeHash() { 1756 1762 t.Fatalf("expected selection to move saved recipe into dismissed, got %#v", updated.Dismissed) 1763 + } 1764 + } 1765 + 1766 + func TestParamsForAction_UsesLatestShoppingListResponseID(t *testing.T) { 1767 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1768 + s := newTestServer(t, withTestCache(cacheStore)) 1769 + 1770 + p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1771 + p.ResponseID = "resp-stale" 1772 + originHash := p.Hash() 1773 + if err := s.SaveParams(t.Context(), p); err != nil { 1774 + t.Fatalf("failed to save params: %v", err) 1775 + } 1776 + recipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved", OriginHash: originHash} 1777 + if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1778 + Recipes: []ai.Recipe{recipe}, 1779 + ResponseID: "resp-fresh", 1780 + }, originHash); err != nil { 1781 + t.Fatalf("failed to save shopping list: %v", err) 1782 + } 1783 + 1784 + updated, err := s.paramsForAction(t.Context(), originHash, "user-1", "") 1785 + if err != nil { 1786 + t.Fatalf("paramsForAction failed: %v", err) 1787 + } 1788 + if got, want := updated.ResponseID, "resp-fresh"; got != want { 1789 + t.Fatalf("expected latest response id %q, got %q", want, got) 1757 1790 } 1758 1791 } 1759 1792
+16
internal/recipes/staples.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/base64" 5 6 "errors" 6 7 "fmt" 8 + "hash/fnv" 9 + "io" 7 10 "log/slog" 11 + "strings" 8 12 "testing" 9 13 "time" 10 14 ··· 17 21 "careme/internal/walmart" 18 22 "careme/internal/wholefoods" 19 23 24 + "github.com/samber/lo" 20 25 "github.com/samber/lo/mutable" 21 26 ) 22 27 ··· 115 120 return nil, err 116 121 } 117 122 return ingredients, nil 123 + } 124 + 125 + // this is not actually wine specificexcept that GetIngredients only does wine requests from ui 126 + // command line could still call it kroger style. 127 + func wineIngredientsCacheKey(style, location string, date time.Time) string { 128 + normalizedStyle := strings.ToLower(strings.TrimSpace(style)) 129 + fnv := fnv.New64a() 130 + lo.Must(io.WriteString(fnv, location)) 131 + lo.Must(io.WriteString(fnv, date.Format("2006-01-02"))) 132 + lo.Must(io.WriteString(fnv, normalizedStyle)) 133 + return "wines/" + base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 118 134 } 119 135 120 136 func (s *cachedStaplesService) GetIngredients(ctx context.Context, locationID string, searchTerm string, skip int, date time.Time) ([]kroger.Ingredient, error) {
+4 -3
internal/recipes/thread.go
··· 14 14 const recipeThreadPrefix = "recipe_thread/" 15 15 16 16 type RecipeThreadEntry struct { 17 - Question string `json:"question"` 18 - Answer string `json:"answer"` 19 - CreatedAt time.Time `json:"created_at"` 17 + Question string `json:"question"` 18 + Answer string `json:"answer"` 19 + ResponseID string `json:"response_id,omitempty"` 20 + CreatedAt time.Time `json:"created_at"` 20 21 } 21 22 22 23 func (rio recipeio) ThreadFromCache(ctx context.Context, hash string) ([]RecipeThreadEntry, error) {
+6 -3
internal/templates/recipe.html
··· 108 108 <p class="text-xs text-gray-500">Ask about the recipe and share how it turned out.</p> 109 109 </div> 110 110 111 - {{if and .ServerSignedIn .ConversationID}} 111 + {{if and .ServerSignedIn .ResponseID}} 112 112 <form method="POST" 113 113 action="/recipe/{{.RecipeHash}}/question" 114 114 hx-post="/recipe/{{.RecipeHash}}/question" ··· 126 126 placeholder="e.g. Can I swap the protein?" 127 127 class="w-full flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400" /> 128 128 <input type="hidden" name="recipe_title" value="{{.Recipe.Title}}" /> 129 - <input type="hidden" name="conversation_id" value="{{.ConversationID}}" /> 129 + <input id="question-response-id" type="hidden" name="response_id" value="{{.ResponseID}}" /> 130 130 <div class="flex items-center gap-2"> 131 131 <button type="submit" 132 132 id="ask-question-submit" ··· 270 270 271 271 {{define "recipe_thread"}} 272 272 <div id="question-thread"> 273 + {{if .ResponseID}} 274 + <input id="question-response-id" type="hidden" name="response_id" value="{{.ResponseID}}" hx-swap-oob="outerHTML" /> 275 + {{end}} 273 276 {{if .Thread}} 274 277 <div class="space-y-4"> 275 278 {{range .Thread}} ··· 281 284 </div> 282 285 {{end}} 283 286 </div> 284 - {{else if and .ServerSignedIn .ConversationID}} 287 + {{else if and .ServerSignedIn .ResponseID}} 285 288 <p class="text-sm text-gray-500">No questions yet.</p> 286 289 {{end}} 287 290 </div>