···84848585 // Step 6: ask a question on the finalized single recipe page.
8686 question := "Can I use skirt steak instead?"
8787- conversationID := extractHiddenValue(t, mustGetBody(t, client, srv.URL+"/recipe/"+url.PathEscape(savedHash)), "conversation_id")
8787+ responseID := extractHiddenValue(t, mustGetBody(t, client, srv.URL+"/recipe/"+url.PathEscape(savedHash)), "response_id")
8888 questionURL := srv.URL + "/recipe/" + url.PathEscape(savedHash) + "/question"
8989 questionBody := mustPostFormBodyHTMX(t, client, questionURL, url.Values{
9090- "conversation_id": {conversationID},
9191- "question": {question},
9292- "recipe_title": {savedTitle},
9090+ "response_id": {responseID},
9191+ "question": {question},
9292+ "recipe_title": {savedTitle},
9393 })
9494 if !strings.Contains(questionBody, question) {
9595 t.Fatalf("expected question thread to include question %q", question)
+4-3
docs/data-object-flow.md
···1414 - `location` (required)
1515 - `date` (optional, defaulted by store timezone/day boundary)
1616 - `instructions` (optional)
1717- - `conversation_id` (optional)
1717+ - `response_id` (optional)
18183. `handleRecipes` persists that object with `SaveParams(...)` under `params/<params_hash>`.
19194. This saved `params` object is the start signal for generation. `kickgeneration(...)` is launched immediately after.
2020···22222323Async generation path:
24241. `kickgeneration(...)` calls `generator.GenerateRecipes(ctx, params)`.
2525-2. The generator returns an `ai.ShoppingList` containing `Recipes` (and `ConversationID`).
2525+2. The generator returns an `ai.ShoppingList` containing `Recipes` (and `ResponseID`).
26263. `SaveShoppingList(...)` persists:
2727 - `shoppinglist/<params_hash>` -> full `ai.ShoppingList`
2828 - `recipe/<recipe_hash>` -> each recipe object (with `OriginHash = params_hash`)
···55551. Loads old `params` from `params/<hash>`.
56562. Loads current `shoppingList` from `shoppinglist/<hash>`.
57573. Loads `recipeSelection` for `(user_id, hash)`.
5858-4. Merges selection state into params (`mergeParamsWithSelection`), applies new instructions, and carries conversation id when needed.
5858+4. Merges selection state into params (`mergeParamsWithSelection`), applies new instructions, and carries the latest response id when needed.
59595. Computes a new hash from the updated params.
60606161Then:
···6565Result:
6666- `selection` holds transient decision state for a given origin hash.
6767- A new generation cycle begins when a new `params` object is created and saved.
6868+- 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
···1515 "careme/internal/locations"
16161717 openai "github.com/openai/openai-go/v3"
1818- "github.com/openai/openai-go/v3/conversations"
1918 "github.com/openai/openai-go/v3/option"
2019 "github.com/openai/openai-go/v3/responses"
2120 "github.com/samber/lo"
···5251 Health string `json:"health"`
5352 DrinkPairing string `json:"drink_pairing"`
5453 WineStyles []string `json:"wine_styles"`
5454+ ResponseID string `json:"response_id,omitempty" jsonschema:"-"` // not in schema
5555 OriginHash string `json:"origin_hash,omitempty" jsonschema:"-"` // not in schema
5656 ParentHash string `json:"parent_hash,omitempty" jsonschema:"-"` // regeneration metadata, not in schema
5757 Saved bool `json:"previously_saved,omitempty" jsonschema:"-"` // not in schema
···8080 return base64.URLEncoding.EncodeToString(fnv.Sum(nil))
8181}
82828383-// intionally not including ConversationID to preserve old hashes
8383+// intentionally not including ResponseID to preserve old hashes
8484+// we used to use conversation id here but then you can end up sharing conversations with strangers which is kind of wierd.
8585+// now we can reuse first recipes and people can go off in different directions.
8486type ShoppingList struct {
8585- ConversationID string `json:"conversation_id,omitempty" jsonschema:"-"`
8686- Recipes []Recipe `json:"recipes" jsonschema:"required"`
8787- Discarded []Recipe `json:"-" jsonschema:"-"`
8787+ ResponseID string `json:"response_id,omitempty" jsonschema:"-"`
8888+ Recipes []Recipe `json:"recipes" jsonschema:"required"`
8989+ Discarded []Recipe `json:"-" jsonschema:"-"`
9090+}
9191+9292+// question threads go off from the response that generated the recipe.
9393+type QuestionResponse struct {
9494+ Answer string
9595+ ResponseID string
8896}
89979098type WineSelection struct {
···190198 return nil, fmt.Errorf("failed to parse AI response: %w", err)
191199 }
192200 normalizeWineStyles(&shoppingList)
193193- if resp.Conversation.ID == "" {
194194- return nil, fmt.Errorf("failed to get conversation ID")
201201+ if strings.TrimSpace(resp.ID) == "" {
202202+ return nil, fmt.Errorf("failed to get response ID")
203203+ }
204204+ shoppingList.ResponseID = resp.ID
205205+ for i := range shoppingList.Recipes {
206206+ shoppingList.Recipes[i].ResponseID = shoppingList.ResponseID
195207 }
196196- shoppingList.ConversationID = resp.Conversation.ID
197208198209 return &shoppingList, nil
199210}
···209220 }
210221}
211222212212-func (c *client) Regenerate(ctx context.Context, instructions []string, conversationID string) (*ShoppingList, error) {
213213- if conversationID == "" {
214214- return nil, fmt.Errorf("conversation ID is required for regeneration")
223223+func (c *client) Regenerate(ctx context.Context, instructions []string, previousResponseID string) (*ShoppingList, error) {
224224+ if previousResponseID == "" {
225225+ return nil, fmt.Errorf("response ID is required for regeneration")
215226 }
216227 client := openai.NewClient(option.WithAPIKey(c.apiKey))
217228 messages := cleanInstuctions(instructions)
218229219230 params := responses.ResponseNewParams{
220220- Model: c.model,
231231+ Model: c.model,
232232+ PreviousResponseID: openai.String(previousResponseID),
221233 // only new input
222234 Input: responses.ResponseNewParamsInputUnion{
223235 OfInputItemList: messages,
224236 },
225237 Store: openai.Bool(true),
226226- Conversation: responses.ResponseNewParamsConversationUnion{
227227- OfString: openai.String(conversationID),
228228- },
229229- Text: scheme(c.schema),
238238+ Text: scheme(c.schema),
230239 }
231240 resp, err := client.Responses.New(ctx, params)
232241 if err != nil {
233242 return nil, fmt.Errorf("failed to regenerate recipes: %w", err)
234243 }
235244236236- if resp.Conversation.ID != conversationID {
237237- return nil, fmt.Errorf("conversation ID mismatch in regeneration response")
238238- }
239239-240245 return responseToShoppingList(ctx, resp)
241246}
242247243243-func (c *client) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) {
248248+func (c *client) AskQuestion(ctx context.Context, question string, previousResponseID string) (*QuestionResponse, error) {
244249 question = strings.TrimSpace(question)
245250 if question == "" {
246246- return "", fmt.Errorf("question is required")
251251+ return nil, fmt.Errorf("question is required")
247252 }
248248- if conversationID == "" {
249249- return "", fmt.Errorf("conversation ID is required for questions")
253253+ if previousResponseID == "" {
254254+ return nil, fmt.Errorf("response ID is required for questions")
250255 }
251256 client := openai.NewClient(option.WithAPIKey(c.apiKey))
252257253258 params := responses.ResponseNewParams{
254254- Model: c.model,
255255- 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."),
259259+ Model: c.model,
260260+ PreviousResponseID: openai.String(previousResponseID),
261261+ 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."),
256262 Input: responses.ResponseNewParamsInputUnion{
257263 OfInputItemList: []responses.ResponseInputItemUnionParam{user(question)},
258264 },
259265 Store: openai.Bool(true),
260260- Conversation: responses.ResponseNewParamsConversationUnion{
261261- OfString: openai.String(conversationID),
262262- },
263266 }
264267 resp, err := client.Responses.New(ctx, params)
265268 if err != nil {
266266- return "", fmt.Errorf("failed to answer question: %w", err)
269269+ return nil, fmt.Errorf("failed to answer question: %w", err)
267270 }
268271 answer := strings.TrimSpace(resp.OutputText())
269272 if answer == "" {
270270- return "", fmt.Errorf("empty response from model")
273273+ return nil, fmt.Errorf("empty response from model")
274274+ }
275275+ if strings.TrimSpace(resp.ID) == "" {
276276+ return nil, fmt.Errorf("failed to get response ID for question")
271277 }
272272- return answer, nil
278278+ return &QuestionResponse{
279279+ Answer: answer,
280280+ ResponseID: resp.ID,
281281+ }, nil
273282}
274283275284func (c *client) GenerateRecipeImage(ctx context.Context, recipe Recipe) (*GeneratedImage, error) {
···370379 }
371380372381 client := openai.NewClient(option.WithAPIKey(c.apiKey))
373373- convo, err := client.Conversations.New(ctx, conversations.ConversationNewParams{})
374374- if err != nil {
375375- return nil, fmt.Errorf("failed to create conversation: %w", err)
376376- }
377377-378382 params := responses.ResponseNewParams{
379383 Model: c.model,
380384 Instructions: openai.String(systemMessage),
···383387 OfInputItemList: messages,
384388 },
385389 Store: openai.Bool(true),
386386- Conversation: responses.ResponseNewParamsConversationUnion{
387387- OfConversationObject: &responses.ResponseConversationParam{
388388- ID: convo.ID,
389389- },
390390- },
391391- Text: scheme(c.schema),
390390+ Text: scheme(c.schema),
392391 }
393392 // should we stream. Can we pass past generation.
394393
+26-23
internal/recipes/generator.go
···2233import (
44 "context"
55- "encoding/base64"
65 "fmt"
77- "hash/fnv"
88- "io"
96 "log/slog"
107 "slices"
118 "strings"
···23202421type aiClient interface {
2522 GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error)
2626- Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error)
2727- AskQuestion(ctx context.Context, question string, conversationID string) (string, error)
2323+ Regenerate(ctx context.Context, newinstructions []string, previousResponseID string) (*ai.ShoppingList, error)
2424+ AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error)
2825 GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error)
2926 PickWine(ctx context.Context, recipe ai.Recipe, wines []kroger.Ingredient) (*ai.WineSelection, error)
3027}
···10097 hash := p.Hash()
10198 start := time.Now()
10299103103- if p.ConversationID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) {
104104- slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID)
100100+ // if we have a response id one of the three should be true? Or did they just not care and hit try again?
101101+ if p.ResponseID != "" && (p.Instructions != "" || len(p.Saved) > 0 || len(p.Dismissed) > 0) {
102102+ slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "response_id", p.ResponseID)
105103 instructions := regenerateInstructions(p)
106104107107- shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ConversationID)
105105+ shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ResponseID)
108106 if err != nil {
109107 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err)
110108 }
109109+ // would prefer to do this deepe down in client
110110+ for i := range shoppingList.Recipes {
111111+ shoppingList.Recipes[i].OriginHash = hash
112112+ }
113113+111114 shoppingList, err = g.critiqueAndMaybeRetry(ctx, hash, shoppingList)
112115 if err != nil {
113116 return nil, err
114117 }
118118+115119 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...)
116120117121 slog.InfoContext(ctx, "regenerated chat", "location", p.String(), "duration", time.Since(start), "hash", hash)
···130134 if err != nil {
131135 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err)
132136 }
137137+ // would prefer to do this deepe down in client like response id but have to pass in the hash
138138+ for i := range shoppingList.Recipes {
139139+ shoppingList.Recipes[i].OriginHash = hash
140140+ }
133141134142 shoppingList, err = g.critiqueAndMaybeRetry(ctx, hash, shoppingList)
135143 if err != nil {
136144 return nil, err
137145 }
138146139139- p.ConversationID = shoppingList.ConversationID
147147+ p.ResponseID = shoppingList.ResponseID
140148 slog.InfoContext(ctx, "generated chat", "location", p.String(), "duration", time.Since(start), "hash", hash)
141149 return shoppingList, nil
142150}
143151144144-func (g *generatorService) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) {
145145- return g.aiClient.AskQuestion(ctx, question, conversationID)
152152+// generator not prociding a lot of value here. Should sever just hold an ai client?
153153+func (g *generatorService) AskQuestion(ctx context.Context, question string, previousResponseID string) (*ai.QuestionResponse, error) {
154154+ return g.aiClient.AskQuestion(ctx, question, previousResponseID)
146155}
147156148157func (g *generatorService) GenerateRecipeImage(ctx context.Context, recipe ai.Recipe) (*ai.GeneratedImage, error) {
···160169 return "empty"
161170 }
162171 return *s
163163-}
164164-165165-func wineIngredientsCacheKey(style, location string, date time.Time) string {
166166- normalizedStyle := strings.ToLower(strings.TrimSpace(style))
167167- fnv := fnv.New64a()
168168- lo.Must(io.WriteString(fnv, location))
169169- lo.Must(io.WriteString(fnv, date.Format("2006-01-02")))
170170- lo.Must(io.WriteString(fnv, normalizedStyle))
171171- return "wines/" + base64.RawURLEncoding.EncodeToString(fnv.Sum(nil))
172172}
173173174174func newlySaved(saved []ai.Recipe, priorSavedHashes []string) []string {
···225225 garbageRecipes := lo.Map(garbage, func(r critique.Result, _ int) ai.Recipe { return *r.Recipe })
226226 g.writeStatus(ctx, hash, titles("Making adjustments to these recipes: ", garbageRecipes))
227227228228- if strings.TrimSpace(shoppingList.ConversationID) == "" {
229229- return nil, fmt.Errorf("conversation ID is required for critique retry")
228228+ if strings.TrimSpace(shoppingList.ResponseID) == "" {
229229+ return nil, fmt.Errorf("response ID is required for critique retry")
230230 }
231231232232 // we could also just give all feedback back if any are below score
233233- shoppingList, err := g.aiClient.Regenerate(ctx, critique.RetryInstructions(garbage), shoppingList.ConversationID)
233233+ shoppingList, err := g.aiClient.Regenerate(ctx, critique.RetryInstructions(garbage), shoppingList.ResponseID)
234234 if err != nil {
235235 return nil, fmt.Errorf("failed to regenerate recipes from critique feedback: %w", err)
236236+ }
237237+ for i := range shoppingList.Recipes {
238238+ shoppingList.Recipes[i].OriginHash = hash
236239 }
237240 newRecipes := shoppingList.Recipes
238241 linkToParents(garbage, recipePtrs(newRecipes))
···3636 Directive string `json:"directive,omitempty"` // this is the new one that will be used. Can remove GenerationPrompt after a while.
3737 LastRecipes []string `json:"-"` // this doesn't get populated until after save.
3838 // UserID string `json:"user_id,omitempty"`
3939- ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes?
3939+ ResponseID string `json:"response_id,omitempty"`
4040 // TODO Both should just be title and hash instead of full ai.Recipe
4141 Saved []ai.Recipe `json:"saved_recipes,omitempty"`
4242 Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"`
···6363}
64646565// Hash this is how we find shoppinglists and params
6666-// intentionally not including ConversationID to preserve old hashes
6666+// intentionally not including ResponseID to preserve old hashes
6767func (g *generatorParams) Hash() string {
6868 fnv := fnv.New64a()
6969 lo.Must(io.WriteString(fnv, g.Location.ID))
···130130131131 p := DefaultParams(l, date)
132132 p.Instructions = r.URL.Query().Get("instructions")
133133- p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id"))
133133+ p.ResponseID = strings.TrimSpace(r.URL.Query().Get("response_id"))
134134135135 return p, nil
136136}