ai cooking
0
fork

Configure Feed

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

Previousrecipe (#504)

* move parent matching out of generator

* html tests

* flush out rather that wrapping

* try again

* no buffer

* continue the madness

* user your own helpers

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
572fdf98 fcebff46

+148 -125
+6 -6
internal/recipes/buttons_test.go
··· 41 41 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 42 42 p := DefaultParams(&loc, time.Now()) 43 43 w := httptest.NewRecorder() 44 - FormatShoppingListHTML(t.Context(), p, multiRecipeList, true, w) 45 - html := w.Body.String() 44 + FormatShoppingListHTMLForHash(t.Context(), p, multiRecipeList, nil, true, p.Hash(), w) 45 + html := assertHTTPSuccess(t, w) 46 46 47 47 // Verify HTML is valid 48 48 isValidHTML(t, html) ··· 127 127 p := DefaultParams(&loc, time.Now()) 128 128 p.Saved = []ai.Recipe{listWithSavedRecipe.Recipes[0]} 129 129 w := httptest.NewRecorder() 130 - FormatShoppingListHTML(t.Context(), p, listWithSavedRecipe, true, w) 131 - html := w.Body.String() 130 + FormatShoppingListHTMLForHash(t.Context(), p, listWithSavedRecipe, nil, true, p.Hash(), w) 131 + html := assertHTTPSuccess(t, w) 132 132 133 133 if !strings.Contains(html, `hx-post="/recipes/`) || !strings.Contains(html, `/finalize"`) { 134 134 t.Error("HTML should submit finalize with HTMX POST when a recipe is saved") ··· 155 155 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 156 156 p := DefaultParams(&loc, time.Now()) 157 157 w := httptest.NewRecorder() 158 - FormatShoppingListHTML(t.Context(), p, list, false, w) 159 - html := w.Body.String() 158 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, false, p.Hash(), w) 159 + html := assertHTTPSuccess(t, w) 160 160 161 161 if strings.Contains(html, `type="radio"`) { 162 162 t.Error("HTML should not contain save/dismiss radio inputs when signed out")
-87
internal/recipes/generator.go
··· 10 10 "slices" 11 11 "strings" 12 12 "time" 13 - "unicode" 14 13 15 14 "careme/internal/ai" 16 15 "careme/internal/kroger" ··· 256 255 slog.ErrorContext(ctx, "failed to save generation status", "hash", hash, "status", status, "error", err) 257 256 } 258 257 } 259 - 260 - func linkToParents(garbage []critique.Result, newRecipes []*ai.Recipe) { 261 - parents := lo.Map(garbage, func(result critique.Result, _ int) *ai.Recipe { 262 - return result.Recipe 263 - }) 264 - applyParentHashesByTitleMatch(parents, newRecipes) 265 - } 266 - 267 - func recipePtrs(recipes []ai.Recipe) []*ai.Recipe { 268 - ptrs := make([]*ai.Recipe, 0, len(recipes)) 269 - for i := range recipes { 270 - ptrs = append(ptrs, &recipes[i]) 271 - } 272 - return ptrs 273 - } 274 - 275 - func applyParentHashesByTitleMatch(parents []*ai.Recipe, newRecipes []*ai.Recipe) { 276 - type candidateMatch struct { 277 - new *ai.Recipe 278 - parent *ai.Recipe 279 - score int 280 - } 281 - 282 - matches := make([]candidateMatch, 0, len(newRecipes)*len(parents)) 283 - for _, newRecipe := range newRecipes { 284 - for _, parent := range parents { 285 - score := sharedWords(newRecipe.Title, parent.Title) 286 - if score == 0 { 287 - continue 288 - } 289 - matches = append(matches, candidateMatch{ 290 - new: newRecipe, 291 - parent: parent, 292 - score: score, 293 - }) 294 - } 295 - } 296 - 297 - slices.SortFunc(matches, func(a, b candidateMatch) int { 298 - return b.score - a.score 299 - }) 300 - 301 - used := make(map[*ai.Recipe]bool, len(newRecipes)) 302 - for _, match := range matches { 303 - if match.new == nil || match.parent == nil { 304 - continue 305 - } 306 - if used[match.new] { 307 - continue 308 - } 309 - if used[match.parent] { 310 - continue 311 - } 312 - parentHash := match.parent.ComputeHash() 313 - childHash := match.new.ComputeHash() 314 - if parentHash == "" || childHash == "" || parentHash == childHash { 315 - continue 316 - } 317 - match.new.ParentHash = parentHash 318 - used[match.new] = true 319 - used[match.parent] = true 320 - } 321 - } 322 - 323 - func sharedWords(a, b string) int { 324 - wordsA := wordSet(a) 325 - wordsB := wordSet(b) 326 - return lo.CountBy(lo.Keys(wordsA), func(word string) bool { 327 - return wordsB[word] 328 - }) 329 - } 330 - 331 - func wordSet(title string) map[string]bool { 332 - wordDict := make(map[string]bool) 333 - s := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool { 334 - return !unicode.IsLetter(r) && !unicode.IsNumber(r) 335 - }) 336 - for _, word := range s { 337 - // tiny words not valuable? 338 - if len(word) < 2 { 339 - continue 340 - } 341 - wordDict[word] = true 342 - } 343 - return wordDict 344 - }
+7 -5
internal/recipes/html.go
··· 56 56 Recommendation *ai.WineSelection 57 57 } 58 58 59 - // FormatShoppingListHTML renders the multi-recipe shopping list view. 60 - func FormatShoppingListHTML(ctx context.Context, p *generatorParams, l ai.ShoppingList, signedIn bool, writer http.ResponseWriter) { 61 - FormatShoppingListHTMLForHash(ctx, p, l, nil, signedIn, p.Hash(), writer) 62 - } 63 - 64 59 // FormatShoppingListHTMLForHash renders the multi-recipe shopping list view for a specific hash. 65 60 func FormatShoppingListHTMLForHash(ctx context.Context, p *generatorParams, l ai.ShoppingList, wineRecommendations map[string]*ai.WineSelection, signedIn bool, hash string, writer http.ResponseWriter) { 66 61 dismissedHashes := make(map[string]bool, len(p.Dismissed)) ··· 123 118 ServerSignedIn: signedIn, 124 119 } 125 120 121 + setTextContent(writer) 126 122 if err := templates.ShoppingList.Execute(writer, data); err != nil { 127 123 http.Error(writer, "shopping list template error: "+err.Error(), http.StatusInternalServerError) 128 124 } ··· 172 168 RecipeCritiqueScore: critiqueScore, 173 169 } 174 170 171 + setTextContent(writer) 175 172 if err := templates.Recipe.Execute(writer, data); err != nil { 176 173 http.Error(writer, "recipe template error: "+err.Error(), http.StatusInternalServerError) 177 174 } ··· 196 193 ServerSignedIn: signedIn, 197 194 } 198 195 196 + setTextContent(writer) 199 197 if err := templates.Recipe.ExecuteTemplate(writer, "recipe_image_action", data); err != nil { 200 198 http.Error(writer, "recipe image action template error: "+err.Error(), http.StatusInternalServerError) 201 199 } ··· 212 210 ServerSignedIn: signedIn, 213 211 } 214 212 213 + setTextContent(writer) 215 214 if err := templates.Recipe.ExecuteTemplate(writer, "recipe_image_action_response", data); err != nil { 216 215 http.Error(writer, "recipe image response template error: "+err.Error(), http.StatusInternalServerError) 217 216 } ··· 233 232 ServerSignedIn: signedIn, 234 233 } 235 234 235 + setTextContent(writer) 236 236 if err := templates.Recipe.ExecuteTemplate(writer, "recipe_thread", data); err != nil { 237 237 http.Error(writer, "recipe thread template error: "+err.Error(), http.StatusInternalServerError) 238 238 } ··· 248 248 WineRecommendation: selection, 249 249 } 250 250 251 + setTextContent(writer) 251 252 if err := templates.Recipe.ExecuteTemplate(writer, "recipe_wine", data); err != nil { 252 253 http.Error(writer, "recipe wine template error: "+err.Error(), http.StatusInternalServerError) 253 254 } ··· 282 283 templateName = "shopping_recipe_wine_details_response" 283 284 } 284 285 286 + setTextContent(writer) 285 287 if err := templates.ShoppingList.ExecuteTemplate(writer, templateName, data); err != nil { 286 288 http.Error(writer, "shopping list wine template error: "+err.Error(), http.StatusInternalServerError) 287 289 }
+37 -27
internal/recipes/html_test.go
··· 29 29 } 30 30 } 31 31 32 + func assertHTTPSuccess(t *testing.T, w *httptest.ResponseRecorder) string { 33 + t.Helper() 34 + if w.Code != http.StatusOK { 35 + t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code) 36 + } 37 + return w.Body.String() 38 + } 39 + 32 40 func TestMain(m *testing.M) { 33 41 if err := templates.Init(&config.Config{}, "dummyhash"); err != nil { 34 42 panic(err) ··· 61 69 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 62 70 p := DefaultParams(&loc, time.Now()) 63 71 w := httptest.NewRecorder() 64 - FormatShoppingListHTML(t.Context(), p, list, true, w) 65 - html := w.Body.String() 66 - if w.Code != http.StatusOK { 67 - t.Error("Want ok statuscode") 68 - } 72 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 73 + html := assertHTTPSuccess(t, w) 69 74 isValidHTML(t, html) 70 75 if !strings.Contains(html, "Cook time:") { 71 76 t.Error("shopping list HTML should contain cook time") ··· 97 102 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 98 103 p := DefaultParams(&loc, time.Now()) 99 104 w := httptest.NewRecorder() 100 - FormatShoppingListHTML(t.Context(), p, list, true, w) 101 - html := w.Body.String() 105 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 106 + html := assertHTTPSuccess(t, w) 102 107 103 108 isValidHTML(t, html) 104 109 if !strings.Contains(html, "quail") { ··· 112 117 113 118 templates.Clarityproject = "test456" 114 119 w := httptest.NewRecorder() 115 - FormatShoppingListHTML(t.Context(), p, list, true, w) 120 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 121 + assertHTTPSuccess(t, w) 116 122 if !bytes.Contains(w.Body.Bytes(), []byte("www.clarity.ms/tag/")) { 117 123 t.Error("HTML should contain Clarity script URL") 118 124 } ··· 136 142 137 143 w := httptest.NewRecorder() 138 144 FormatShoppingListHTMLForHash(ctx, p, list, nil, true, p.Hash(), w) 145 + assertHTTPSuccess(t, w) 139 146 if !bytes.Contains(w.Body.Bytes(), []byte(`window.clarity("identify", "sess-123", "sess-123")`)) { 140 147 t.Error("HTML should include Clarity identify call with session id") 141 148 } ··· 146 153 p := DefaultParams(&loc, time.Now()) 147 154 templates.Clarityproject = "" 148 155 w := httptest.NewRecorder() 149 - FormatShoppingListHTML(t.Context(), p, list, true, w) 156 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 157 + assertHTTPSuccess(t, w) 150 158 if bytes.Contains(w.Body.Bytes(), []byte("clarity.ms")) { 151 159 t.Error("HTML should not contain Clarity script when project ID is empty") 152 160 } ··· 162 170 }) 163 171 templates.GoogleTagID = "AW-1234567890" 164 172 w := httptest.NewRecorder() 165 - FormatShoppingListHTML(t.Context(), p, list, true, w) 173 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 174 + assertHTTPSuccess(t, w) 166 175 if !bytes.Contains(w.Body.Bytes(), []byte("www.googletagmanager.com/gtag/js?id=AW-1234567890")) { 167 176 t.Error("HTML should contain Google tag script URL") 168 177 } ··· 181 190 }) 182 191 templates.GoogleTagID = "" 183 192 w := httptest.NewRecorder() 184 - FormatShoppingListHTML(t.Context(), p, list, true, w) 193 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 194 + assertHTTPSuccess(t, w) 185 195 if bytes.Contains(w.Body.Bytes(), []byte("googletagmanager.com")) { 186 196 t.Error("HTML should not contain Google tag script when tag ID is empty") 187 197 } ··· 191 201 loc := locations.Location{ID: "70000001", Name: "Store", Address: "1 Main St"} 192 202 p := DefaultParams(&loc, time.Now()) 193 203 w := httptest.NewRecorder() 194 - FormatShoppingListHTML(t.Context(), p, list, true, w) 195 - html := w.Body.String() 204 + FormatShoppingListHTMLForHash(t.Context(), p, list, nil, true, p.Hash(), w) 205 + html := assertHTTPSuccess(t, w) 196 206 197 207 // Verify "Careme Recipes" is a link to home page 198 208 if !strings.Contains(html, `<a href="/"`) { ··· 209 219 p.ConversationID = "convo123" 210 220 w := httptest.NewRecorder() 211 221 FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 212 - html := w.Body.String() 222 + html := assertHTTPSuccess(t, w) 213 223 214 224 isValidHTML(t, html) 215 225 ··· 287 297 p.ConversationID = "convo123" 288 298 w := httptest.NewRecorder() 289 299 FormatRecipeHTML(t.Context(), p, list.Recipes[0], false, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 290 - html := w.Body.String() 300 + html := assertHTTPSuccess(t, w) 291 301 292 302 isValidHTML(t, html) 293 303 ··· 313 323 score := 8 314 324 315 325 FormatRecipeHTML(t.Context(), p, list.Recipes[0], true, &score, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 316 - html := w.Body.String() 326 + html := assertHTTPSuccess(t, w) 317 327 318 328 isValidHTML(t, html) 319 329 if !strings.Contains(html, "Recipe score:") { ··· 346 356 } 347 357 348 358 w := httptest.NewRecorder() 349 - FormatShoppingListHTML(t.Context(), p, multiRecipeList, false, w) 350 - html := w.Body.String() 359 + FormatShoppingListHTMLForHash(t.Context(), p, multiRecipeList, nil, false, p.Hash(), w) 360 + html := assertHTTPSuccess(t, w) 351 361 352 362 isValidHTML(t, html) 353 363 if strings.Contains(html, `/recipes/`) && strings.Contains(html, `/regenerate"`) { ··· 391 401 }, 392 402 Commentary: "Great with the savory notes.", 393 403 }, w) 394 - html := w.Body.String() 404 + html := assertHTTPSuccess(t, w) 395 405 396 406 isValidHTML(t, html) 397 407 ··· 431 441 } 432 442 433 443 FormatRecipeHTML(t.Context(), p, recipe, true, nil, false, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 434 - html := w.Body.String() 444 + html := assertHTTPSuccess(t, w) 435 445 436 446 isValidHTML(t, html) 437 447 if !strings.Contains(html, "Little gem lettuce") { ··· 466 476 }, 467 477 } 468 478 469 - FormatShoppingListHTML(t.Context(), p, listWithoutPrice, true, w) 470 - html := w.Body.String() 479 + FormatShoppingListHTMLForHash(t.Context(), p, listWithoutPrice, nil, true, p.Hash(), w) 480 + html := assertHTTPSuccess(t, w) 471 481 472 482 isValidHTML(t, html) 473 483 if !strings.Contains(html, "English peas") { ··· 490 500 recipeHash := recipe.ComputeHash() 491 501 492 502 FormatRecipeHTML(t.Context(), p, recipe, true, nil, true, []RecipeThreadEntry{}, feedback.Feedback{}, nil, w) 493 - html := w.Body.String() 503 + html := assertHTTPSuccess(t, w) 494 504 495 505 isValidHTML(t, html) 496 506 ··· 543 553 Commentary: "Good with roasted flavors.", 544 554 }, 545 555 }, true, p.Hash(), w) 546 - html := w.Body.String() 556 + html := assertHTTPSuccess(t, w) 547 557 548 558 isValidHTML(t, html) 549 559 ··· 588 598 func TestFormatShoppingRecipeWineHTML_RendersPicker(t *testing.T) { 589 599 w := httptest.NewRecorder() 590 600 FormatShoppingRecipeWineHTML("recipe-hash", "action", nil, w) 591 - body := w.Body.String() 601 + body := assertHTTPSuccess(t, w) 592 602 actionID, _ := shoppingWineDOMIDs("recipe-hash") 593 603 previewID := shoppingWinePreviewDOMID("recipe-hash") 594 604 detailContainerID, _ := shoppingWineDetailDOMIDs("recipe-hash") ··· 624 634 } 625 635 626 636 FormatRecipeThreadHTML(thread, true, "conv123", w) 627 - body := w.Body.String() 637 + body := assertHTTPSuccess(t, w) 628 638 629 639 newerIndex := strings.Index(body, "newer question") 630 640 olderIndex := strings.Index(body, "older question") ··· 646 656 }, 647 657 Commentary: "Try a light pinot noir.", 648 658 }, w) 649 - body := w.Body.String() 659 + body := assertHTTPSuccess(t, w) 650 660 651 661 if !strings.Contains(body, `id="wine-recommendation"`) { 652 662 t.Fatalf("expected wine fragment container in response, got body: %s", body)
+98
internal/recipes/parent_links.go
··· 1 + package recipes 2 + 3 + import ( 4 + "slices" 5 + "strings" 6 + "unicode" 7 + 8 + "careme/internal/ai" 9 + "careme/internal/recipes/critique" 10 + 11 + "github.com/samber/lo" 12 + ) 13 + 14 + func linkToParents(garbage []critique.Result, newRecipes []*ai.Recipe) { 15 + parents := lo.Map(garbage, func(result critique.Result, _ int) *ai.Recipe { 16 + return result.Recipe 17 + }) 18 + applyParentHashesByTitleMatch(parents, newRecipes) 19 + } 20 + 21 + func recipePtrs(recipes []ai.Recipe) []*ai.Recipe { 22 + ptrs := make([]*ai.Recipe, 0, len(recipes)) 23 + for i := range recipes { 24 + ptrs = append(ptrs, &recipes[i]) 25 + } 26 + return ptrs 27 + } 28 + 29 + func applyParentHashesByTitleMatch(parents []*ai.Recipe, newRecipes []*ai.Recipe) { 30 + type candidateMatch struct { 31 + new *ai.Recipe 32 + parent *ai.Recipe 33 + score int 34 + } 35 + 36 + matches := make([]candidateMatch, 0, len(newRecipes)*len(parents)) 37 + for _, newRecipe := range newRecipes { 38 + for _, parent := range parents { 39 + score := sharedWords(newRecipe.Title, parent.Title) 40 + if score == 0 { 41 + continue 42 + } 43 + matches = append(matches, candidateMatch{ 44 + new: newRecipe, 45 + parent: parent, 46 + score: score, 47 + }) 48 + } 49 + } 50 + 51 + slices.SortFunc(matches, func(a, b candidateMatch) int { 52 + return b.score - a.score 53 + }) 54 + 55 + used := make(map[*ai.Recipe]bool, len(newRecipes)) 56 + for _, match := range matches { 57 + if match.new == nil || match.parent == nil { 58 + continue 59 + } 60 + if used[match.new] { 61 + continue 62 + } 63 + if used[match.parent] { 64 + continue 65 + } 66 + parentHash := match.parent.ComputeHash() 67 + childHash := match.new.ComputeHash() 68 + if parentHash == "" || childHash == "" || parentHash == childHash { 69 + continue 70 + } 71 + match.new.ParentHash = parentHash 72 + used[match.new] = true 73 + used[match.parent] = true 74 + } 75 + } 76 + 77 + func sharedWords(a, b string) int { 78 + wordsA := wordSet(a) 79 + wordsB := wordSet(b) 80 + return lo.CountBy(lo.Keys(wordsA), func(word string) bool { 81 + return wordsB[word] 82 + }) 83 + } 84 + 85 + func wordSet(title string) map[string]bool { 86 + wordDict := make(map[string]bool) 87 + s := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool { 88 + return !unicode.IsLetter(r) && !unicode.IsNumber(r) 89 + }) 90 + for _, word := range s { 91 + // tiny words not valuable? 92 + if len(word) < 2 { 93 + continue 94 + } 95 + wordDict[word] = true 96 + } 97 + return wordDict 98 + }