···7171 }
7272 }
73737474+ // Step 6: ask a question on the finalized single recipe page.
7575+ question := "Can I use skirt steak instead?"
7676+ questionURL := srv.URL + "/recipe/" + url.PathEscape(savedHash) + "/question"
7777+ questionBody := mustPostFormBody(t, client, questionURL, url.Values{
7878+ "conversation_id": {conversationID},
7979+ "question": {question},
8080+ })
8181+ if !strings.Contains(questionBody, question) {
8282+ t.Fatalf("expected question thread to include question %q", question)
8383+ }
8484+ if !strings.Contains(questionBody, "Mock answer: "+question) {
8585+ t.Fatalf("expected question thread to include mock answer for %q", question)
8686+ }
8787+7488 //TODO step 6 make sure recipes are saved to user page?
75897690}
···135149 }
136150 body := readAll(t, resp.Body)
137151 requireValidHTML(t, url, resp.Header.Get("Content-Type"), body)
152152+ return body
153153+}
154154+155155+func mustPostFormBody(t *testing.T, client *http.Client, targetURL string, data url.Values) string {
156156+ t.Helper()
157157+ resp, err := client.PostForm(targetURL, data)
158158+ if err != nil {
159159+ t.Fatalf("POST %s failed: %v", targetURL, err)
160160+ }
161161+ defer func() {
162162+ if err := resp.Body.Close(); err != nil {
163163+ t.Fatalf("failed to close response body: %v", err)
164164+ }
165165+ }()
166166+ if resp.StatusCode != http.StatusOK {
167167+ body := readAll(t, resp.Body)
168168+ t.Fatalf("POST %s expected 200 after redirect, got %d: %s", targetURL, resp.StatusCode, body)
169169+ }
170170+ body := readAll(t, resp.Body)
171171+ requireValidHTML(t, targetURL, resp.Header.Get("Content-Type"), body)
138172 return body
139173}
140174
+34-2
internal/ai/client.go
···104104# Instructions
105105- Each meal must feature a protein and at least one side of either a vegetable and/or a starch. A combined dish (such as a pasta, stew, or similar) that incorporates a vegetable or starch is also good.
106106- Recipes should use diverse cooking methods and represent a variety of cuisines.
107107-- Provide clear, step-by-step instructions and an ingredient list for each recipe. repeat amounts and prep for each recipe in instructions.
107107+- Provide clear, step-by-step instructions and an ingredient list for each recipe. repeat amounts and prep for each recipe in instructions.
108108- Recipes should take under 1 hour to prepare, unless the user asks for something longer
109109- Optionally include a wine pairing suggestion for each recipe if appropriate. Suggest a couple of styles and a local brand if possible. Really put your Sommielier hat on for this.
110110-- Prioritize ingredients that are on sale (the bigger the discount, the higher the priority but but don't pay more for something on sale than a similar ingredient that isn't)
110110+- Prioritize ingredients that are on sale (the bigger the discount, the higher the priority but but don't pay more for something on sale than a similar ingredient that isn't)
111111112112113113# Output Format
···172172 }
173173174174 return responseToShoppingList(ctx, resp)
175175+}
176176+177177+func (c *Client) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) {
178178+ question = strings.TrimSpace(question)
179179+ if question == "" {
180180+ return "", fmt.Errorf("question is required")
181181+ }
182182+ if conversationID == "" {
183183+ return "", fmt.Errorf("conversation ID is required for questions")
184184+ }
185185+ client := openai.NewClient(option.WithAPIKey(c.apiKey))
186186+187187+ params := responses.ResponseNewParams{
188188+ Model: c.model,
189189+ 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."),
190190+ Input: responses.ResponseNewParamsInputUnion{
191191+ OfInputItemList: []responses.ResponseInputItemUnionParam{user(question)},
192192+ },
193193+ Store: openai.Bool(true),
194194+ Conversation: responses.ResponseNewParamsConversationUnion{
195195+ OfString: openai.String(conversationID),
196196+ },
197197+ }
198198+ resp, err := client.Responses.New(ctx, params)
199199+ if err != nil {
200200+ return "", fmt.Errorf("failed to answer question: %w", err)
201201+ }
202202+ answer := strings.TrimSpace(resp.OutputText())
203203+ if answer == "" {
204204+ return "", fmt.Errorf("empty response from model")
205205+ }
206206+ return answer, nil
175207}
176208177209// is this dependency on krorger unncessary? just pass in a blob of toml or whatever? same with last recipes?
···115115 p := DefaultParams(&loc, time.Now())
116116 p.ConversationID = "convo123"
117117 w := httptest.NewRecorder()
118118- FormatRecipeHTML(p, list.Recipes[0], true, w)
118118+ FormatRecipeHTML(p, list.Recipes[0], true, []RecipeThreadEntry{}, w)
119119 html := w.Body.String()
120120121121 isValidHTML(t, html)
···129129 if strings.Contains(html, `name="saved"`) || strings.Contains(html, `name="dismissed"`) {
130130 t.Error("recipe HTML should not contain save/dismiss inputs")
131131 }
132132+ if !strings.Contains(html, `name="question"`) {
133133+ t.Error("recipe HTML should contain question input")
134134+ }
135135+}
136136+137137+func TestFormatRecipeHTML_HidesQuestionInputWhenSignedOut(t *testing.T) {
138138+ loc := locations.Location{ID: "L1", Name: "Store", Address: "1 Main St"}
139139+ p := DefaultParams(&loc, time.Now())
140140+ p.ConversationID = "convo123"
141141+ w := httptest.NewRecorder()
142142+ FormatRecipeHTML(p, list.Recipes[0], false, []RecipeThreadEntry{}, w)
143143+ html := w.Body.String()
144144+145145+ isValidHTML(t, html)
146146+132147 if strings.Contains(html, `name="question"`) {
133133- t.Error("recipe HTML should not contain question input")
148148+ t.Error("recipe HTML should not contain question input when signed out")
149149+ }
150150+ if !strings.Contains(html, "Sign in to ask follow-up questions.") {
151151+ t.Error("recipe HTML should prompt signed-out users to sign in for questions")
134152 }
135153}