ai cooking
0
fork

Configure Feed

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

Spin should give some updates. (#497)

* okay that seemed to have worked

* status updates building

* passing tests

* just take what you need

* spinner doesn't care if you're signed in genrateion has already started

* fine leave serversinged in but hard code it

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
06161bee 78cba699

+219 -54
+2 -1
cmd/careme/web.go
··· 77 77 return fmt.Errorf("failed to create staples service: %w", err) 78 78 } 79 79 watchdogServer.Add("staples", staples, 6.*time.Hour) 80 - generator, err = recipes.NewGenerator(aiclient, mc, staples) 80 + ss := recipes.StatusStore(cache) 81 + generator, err = recipes.NewGenerator(aiclient, mc, staples, ss) 81 82 if err != nil { 82 83 return fmt.Errorf("failed to create recipe generator: %w", err) 83 84 }
+1
docs/cache-layout.md
··· 29 29 | `shoppinglist/` | JSON `ai.ShoppingList` keyed by shopping hash | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`FromCache`) | 30 30 | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash (staples) or by wine style/date/location hash (wine candidate cache) | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`, `PickAWine`) | 31 31 | `params/` | JSON `generatorParams` keyed by shopping hash; params no longer embed the resolved staple filter list | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 32 + | `generation_status/` | JSON `recipes.GenerationStatus` (`stage`, `message`, `updated_at`) keyed by shopping hash for spinner progress | `internal/recipes/generation_status.go` (`SaveGenerationStatus`) via `internal/recipes/server.go` (`kickgeneration`) and `internal/recipes/generator.go` (`GenerateRecipes`) | `internal/recipes/generation_status.go` (`GenerationStatusFromCache`) via `internal/recipes/server.go` (`Spin`) | 32 33 | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveShoppingList`) | `internal/recipes/io.go` (`SingleFromCache`) | 33 34 | `recipe_images/` | WebP bytes for single-recipe dish images keyed by recipe hash in the dedicated `recipe-images` cache backend | `internal/recipes/image.go` (`SaveRecipeImage`) via `internal/recipes/server.go` (`POST /recipe/{hash}/image`) | `internal/recipes/image.go` (`RecipeImageFromCache`, `RecipeImageExists`) via `internal/recipes/server.go` (`GET /recipe/{hash}/image`, `handleSingle`) | 34 35 | `wine_recommendations/` | Plain text wine recommendation keyed by recipe hash | `internal/recipes/wine.go` (`SaveWine`) via `internal/recipes/server.go` (`handleWine`) | `internal/recipes/wine.go` (`WineFromCache`) via `internal/recipes/server.go` (`handleWine`) |
+3 -1
internal/mail/mail.go
··· 75 75 if err != nil { 76 76 return nil, fmt.Errorf("failed to create staples service: %w", err) 77 77 } 78 - generator, err := recipes.NewGenerator(ai.NewClient(cfg.AI.APIKey, "TODOMODEL"), mc, staples) 78 + ss := recipes.StatusStore(cache) 79 + aiClient := ai.NewClient(cfg.AI.APIKey, "TODOMODEL") 80 + generator, err := recipes.NewGenerator(aiClient, mc, staples, ss) 79 81 if err != nil { 80 82 return nil, fmt.Errorf("failed to create recipe generator: %w", err) 81 83 }
+51
internal/recipes/generation_status.go
··· 1 + package recipes 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + 9 + "careme/internal/cache" 10 + ) 11 + 12 + const generationStatusCachePrefix = "generation_status/" 13 + 14 + type statusWriter interface { 15 + SaveGenerationStatus(ctx context.Context, hash string, status string) error 16 + } 17 + 18 + type statusReader interface { 19 + GenerationStatusFromCache(ctx context.Context, hash string) (string, error) 20 + } 21 + 22 + type statusStore struct { 23 + cache cache.Cache 24 + } 25 + 26 + var ( 27 + _ statusReader = &statusStore{} 28 + _ statusWriter = &statusStore{} 29 + ) 30 + 31 + func StatusStore(c cache.Cache) *statusStore { 32 + return &statusStore{c} 33 + } 34 + 35 + func (ss statusStore) GenerationStatusFromCache(ctx context.Context, hash string) (string, error) { 36 + statusReader, err := ss.cache.Get(ctx, generationStatusCachePrefix+hash) 37 + if err != nil { 38 + return "", fmt.Errorf("error getting generation status for hash %s: %w", hash, err) 39 + } 40 + defer func() { 41 + if err := statusReader.Close(); err != nil { 42 + slog.ErrorContext(ctx, "failed to close generation status reader", "hash", hash, "error", err) 43 + } 44 + }() 45 + b, err := io.ReadAll(statusReader) 46 + return string(b), err 47 + } 48 + 49 + func (ss statusStore) SaveGenerationStatus(ctx context.Context, hash, status string) error { 50 + return ss.cache.Put(ctx, generationStatusCachePrefix+hash, status, cache.Unconditional()) 51 + }
+40 -12
internal/recipes/generator.go
··· 36 36 } 37 37 38 38 type generatorService struct { 39 - aiClient aiClient 40 - critiquer critique.Service 41 - staples staplesService 39 + aiClient aiClient 40 + critiquer critique.Service 41 + staples staplesService 42 + statusWriter statusWriter 42 43 } 43 44 44 - func NewGenerator(aiClient aiClient, critiquer critique.Service, staples staplesService) (*generatorService, error) { 45 + func NewGenerator(aiClient aiClient, critiquer critique.Service, staples staplesService, statuses statusWriter) (*generatorService, error) { 45 46 if aiClient == nil { 46 47 return nil, fmt.Errorf("ai client is required") 47 48 } ··· 52 53 return nil, fmt.Errorf("staples service is required") 53 54 } 54 55 return &generatorService{ 55 - aiClient: aiClient, 56 - critiquer: critiquer, 57 - staples: staples, 56 + aiClient: aiClient, 57 + critiquer: critiquer, 58 + staples: staples, 59 + statusWriter: statuses, 58 60 }, nil 59 61 } 60 62 ··· 115 117 if err != nil { 116 118 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) 117 119 } 118 - shoppingList, err = g.critiqueAndMaybeRetry(ctx, shoppingList) 120 + shoppingList, err = g.critiqueAndMaybeRetry(ctx, hash, shoppingList) 119 121 if err != nil { 120 122 return nil, err 121 123 } ··· 130 132 if err != nil { 131 133 return nil, fmt.Errorf("failed to get staples: %w", err) 132 134 } 135 + g.writeStatus(ctx, hash, fmt.Sprintf("Looking through %d ingredients", len(ingredients))) 133 136 134 137 instructions := []string{p.Directive, p.Instructions} 135 138 shoppingList, err := g.aiClient.GenerateRecipes(ctx, p.Location, ingredients, instructions, p.Date, p.LastRecipes) 136 139 if err != nil { 137 140 return nil, fmt.Errorf("failed to generate recipes with AI: %w", err) 138 141 } 139 - shoppingList, err = g.critiqueAndMaybeRetry(ctx, shoppingList) 142 + 143 + shoppingList, err = g.critiqueAndMaybeRetry(ctx, hash, shoppingList) 140 144 if err != nil { 141 145 return nil, err 142 146 } ··· 188 192 return lo.Uniq(titles) 189 193 } 190 194 191 - func (g *generatorService) critiqueAndMaybeRetry(ctx context.Context, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 195 + func titles(prefix string, recipes []ai.Recipe) string { 196 + var b strings.Builder 197 + b.WriteString(prefix) 198 + b.WriteString("\n") 199 + for _, r := range recipes { 200 + b.WriteString(r.Title) 201 + b.WriteString("\n") 202 + } 203 + return b.String() 204 + } 205 + 206 + func (g *generatorService) critiqueAndMaybeRetry(ctx context.Context, hash string, shoppingList *ai.ShoppingList) (*ai.ShoppingList, error) { 192 207 if g.critiquer == nil { 193 208 return shoppingList, nil 194 209 } 195 - 210 + g.writeStatus(ctx, hash, titles("Getting feeeback on these recipes:", shoppingList.Recipes)) 196 211 results := g.critiquer.CritiqueRecipes(ctx, shoppingList.Recipes) 197 212 good, garbage := critique.Split(ctx, results, critique.MinimumRecipeScore) 198 213 for _, result := range garbage { ··· 201 216 if len(garbage) == 0 { 202 217 return shoppingList, nil 203 218 } 204 - slog.InfoContext(ctx, "regenerating recipes based on critique feedback", "garbage_count", len(garbage), "good_count", len(good)) 219 + slog.InfoContext(ctx, "Regenerating recipes based on critique feedback:", "garbage_count", len(garbage), "good_count", len(good)) 220 + garbageRecipes := lo.Map(garbage, func(r critique.Result, _ int) ai.Recipe { return *r.Recipe }) 221 + g.writeStatus(ctx, hash, titles("Making adjustments to these recipes: ", garbageRecipes)) 205 222 206 223 if strings.TrimSpace(shoppingList.ConversationID) == "" { 207 224 return nil, fmt.Errorf("conversation ID is required for critique retry") ··· 219 236 }) 220 237 221 238 _ = g.critiquer.CritiqueRecipes(ctx, newRecipes) 239 + // no point in upating as we're async here g.updateGenerationStatus(ctx, hash, "") 222 240 return shoppingList, nil 223 241 } 242 + 243 + // just making this best effort 244 + func (g *generatorService) writeStatus(ctx context.Context, hash string, status string) { 245 + if strings.TrimSpace(hash) == "" { 246 + return 247 + } 248 + if err := g.statusWriter.SaveGenerationStatus(ctx, hash, status); err != nil { 249 + slog.ErrorContext(ctx, "failed to save generation status", "hash", hash, "status", status, "error", err) 250 + } 251 + }
+65 -19
internal/recipes/generator_test.go
··· 13 13 "careme/internal/kroger" 14 14 "careme/internal/locations" 15 15 "careme/internal/recipes/critique" 16 + 17 + "github.com/stretchr/testify/assert" 18 + "github.com/stretchr/testify/require" 16 19 ) 17 20 18 21 type captureWineQuestionAIClient struct { ··· 416 419 } 417 420 critiquer := &captureCritiqueService{} 418 421 g := &generatorService{ 419 - staples: &cachedStaplesService{cache: io}, 420 - aiClient: aiStub, 421 - critiquer: critiquer, 422 + staples: &cachedStaplesService{cache: io}, 423 + aiClient: aiStub, 424 + critiquer: critiquer, 425 + statusWriter: noopstatuswriter{}, 422 426 } 423 427 424 428 got, err := g.GenerateRecipes(t.Context(), params) ··· 436 440 } 437 441 } 438 442 443 + type noopstatuswriter struct{} 444 + 445 + func (noopstatuswriter) SaveGenerationStatus(_ context.Context, _, _ string) error { return nil } 446 + 439 447 func TestGenerateRecipes_RegenerateCritiquesOnlyFreshRecipes(t *testing.T) { 440 448 alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 441 449 newResult := ai.Recipe{Title: "Brand New Dinner", Description: "Fresh idea"} 442 450 443 451 critiquer := &captureCritiqueService{} 444 452 g := &generatorService{ 445 - aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ConversationID: "conv-123", Recipes: []ai.Recipe{newResult}}}, 446 - critiquer: critiquer, 453 + aiClient: &captureRegenerateAIClient{shoppingList: &ai.ShoppingList{ConversationID: "conv-123", Recipes: []ai.Recipe{newResult}}}, 454 + critiquer: critiquer, 455 + statusWriter: noopstatuswriter{}, 447 456 } 448 457 449 458 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) ··· 512 521 } 513 522 514 523 g := &generatorService{ 515 - staples: &cachedStaplesService{cache: io}, 516 - aiClient: aiStub, 517 - critiquer: critiquer, 524 + staples: &cachedStaplesService{cache: io}, 525 + aiClient: aiStub, 526 + critiquer: critiquer, 527 + statusWriter: noopstatuswriter{}, 518 528 } 519 529 520 530 got, err := g.GenerateRecipes(t.Context(), params) ··· 598 608 }, 599 609 } 600 610 g := &generatorService{ 601 - staples: &cachedStaplesService{cache: io}, 602 - aiClient: aiStub, 603 - critiquer: critiquer, 611 + staples: &cachedStaplesService{cache: io}, 612 + aiClient: aiStub, 613 + critiquer: critiquer, 614 + statusWriter: noopstatuswriter{}, 604 615 } 605 616 606 617 got, err := g.GenerateRecipes(t.Context(), params) ··· 632 643 }}, 633 644 } 634 645 g := &generatorService{ 635 - staples: &cachedStaplesService{cache: io}, 636 - aiClient: aiStub, 637 - critiquer: &captureCritiqueService{}, 646 + staples: &cachedStaplesService{cache: io}, 647 + aiClient: aiStub, 648 + critiquer: &captureCritiqueService{}, 649 + statusWriter: noopstatuswriter{}, 638 650 } 639 651 640 652 got, err := g.GenerateRecipes(t.Context(), params) ··· 649 661 } 650 662 } 651 663 664 + type statusCounter struct { 665 + status []string 666 + } 667 + 668 + func (s *statusCounter) SaveGenerationStatus(_ context.Context, _, stat string) error { 669 + s.status = append(s.status, stat) 670 + return nil 671 + } 672 + 673 + func TestGenerateRecipes_WritesStatusStagesForInitialGeneration(t *testing.T) { 674 + steady := ai.Recipe{Title: "Steady Dinner", Description: "Good enough"} 675 + 676 + cacheStore := cache.NewFileCache(t.TempDir()) 677 + io := IO(cacheStore) 678 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 679 + if err := io.SaveIngredients(t.Context(), params.LocationHash(), []kroger.Ingredient{{Description: loPtr("Chicken")}}); err != nil { 680 + t.Fatalf("failed to seed ingredients cache: %v", err) 681 + } 682 + 683 + statuses := &statusCounter{} 684 + g := &generatorService{ 685 + staples: &cachedStaplesService{cache: io}, 686 + aiClient: &sequenceAIClient{generateResponses: []*ai.ShoppingList{{ConversationID: "conv-stable", Recipes: []ai.Recipe{steady}}}}, 687 + critiquer: &captureCritiqueService{}, 688 + statusWriter: statuses, 689 + } 690 + 691 + _, err := g.GenerateRecipes(t.Context(), params) 692 + require.NoError(t, err) 693 + assert.Equal(t, 2, len(statuses.status), "got statuses %v", statuses.status) 694 + } 695 + 652 696 func TestGenerateRecipes_RegenerateRetriesLowScoringRecipesOnce(t *testing.T) { 653 697 alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 654 698 initial := ai.Recipe{Title: "Needs Work", Description: "First pass"} ··· 694 738 }, 695 739 } 696 740 g := &generatorService{ 697 - aiClient: aiStub, 698 - critiquer: critiquer, 741 + aiClient: aiStub, 742 + critiquer: critiquer, 743 + statusWriter: noopstatuswriter{}, 699 744 } 700 745 701 746 params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) ··· 765 810 }, 766 811 } 767 812 g := &generatorService{ 768 - staples: &cachedStaplesService{cache: io}, 769 - aiClient: aiStub, 770 - critiquer: critiquer, 813 + staples: &cachedStaplesService{cache: io}, 814 + aiClient: aiStub, 815 + critiquer: critiquer, 816 + statusWriter: noopstatuswriter{}, 771 817 } 772 818 773 819 got, err := g.GenerateRecipes(t.Context(), params)
+27 -20
internal/recipes/server.go
··· 84 84 type server struct { 85 85 recipeio 86 86 imageio 87 - cfg *config.Config 88 - storage *users.Storage 89 - generator generator 90 - locServer locServer 91 - wg sync.WaitGroup 92 - clerk auth.AuthClient 87 + statusReader statusReader 88 + cfg *config.Config 89 + storage *users.Storage 90 + generator generator 91 + locServer locServer 92 + wg sync.WaitGroup 93 + clerk auth.AuthClient 93 94 } 94 95 95 96 // NewHandler returns an http.Handler serving the recipe endpoints under /recipes. 96 97 // cache must be connected to generator or this will not work. Should we enfroce that by getting cache from generator? 97 98 func NewHandler(cfg *config.Config, storage *users.Storage, generator generator, locServer locServer, c cache.Cache, imageCache cache.Cache, clerkClient auth.AuthClient) *server { 98 99 return &server{ 99 - recipeio: IO(c), 100 - imageio: imageio{Cache: imageCache}, 101 - cfg: cfg, 102 - storage: storage, 103 - generator: generator, 104 - locServer: locServer, 105 - clerk: clerkClient, 100 + recipeio: IO(c), 101 + imageio: imageio{Cache: imageCache}, 102 + statusReader: StatusStore(c), 103 + cfg: cfg, 104 + storage: storage, 105 + generator: generator, 106 + locServer: locServer, 107 + clerk: clerkClient, 106 108 } 107 109 } 108 110 ··· 859 861 } 860 862 861 863 if time.Since(startTime) < time.Minute*10 { 862 - s.Spin(w, r) 864 + s.spin(ctx, w, hashParam) 863 865 return 864 866 } 865 867 slog.WarnContext(ctx, "rekicking generation", "time", startArg, "hash", hashParam) ··· 1054 1056 }) 1055 1057 } 1056 1058 1057 - func (s *server) Spin(w http.ResponseWriter, r *http.Request) { 1059 + func (s *server) spin(ctx context.Context, w http.ResponseWriter, hash string) { 1058 1060 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") 1059 - ctx := r.Context() 1060 - _, err := s.clerk.GetUserIDFromRequest(r) 1061 - signedIn := !errors.Is(err, auth.ErrNoSession) 1061 + 1062 + status, err := s.statusReader.GenerationStatusFromCache(ctx, hash) 1063 + if err != nil && !errors.Is(err, cache.ErrNotFound) { 1064 + slog.ErrorContext(ctx, "failed to load generation status", "hash", hash, "error", err) 1065 + } 1066 + 1062 1067 spinnerData := struct { 1063 1068 ClarityScript template.HTML 1064 1069 GoogleTagScript template.HTML 1065 1070 Style seasons.Style 1066 - ServerSignedIn bool 1067 1071 RefreshInterval string // seconds 1072 + StatusMessage string 1073 + ServerSignedIn bool 1068 1074 }{ 1069 1075 ClarityScript: templates.ClarityScript(ctx), 1070 1076 GoogleTagScript: templates.GoogleTagScript(), 1071 1077 Style: seasons.GetCurrentStyle(), 1072 - ServerSignedIn: signedIn, 1073 1078 RefreshInterval: "10", // seconds 1079 + StatusMessage: status, 1080 + ServerSignedIn: true, // clerk refresh doesn't need to reload because spin will just do it anwyays 1074 1081 } 1075 1082 1076 1083 if err := templates.Spin.Execute(w, spinnerData); err != nil {
+23
internal/recipes/server_test.go
··· 24 24 "careme/internal/users" 25 25 26 26 utypes "careme/internal/users/types" 27 + 28 + "github.com/stretchr/testify/assert" 29 + "github.com/stretchr/testify/require" 27 30 ) 28 31 29 32 func TestRedirectToHash(t *testing.T) { ··· 635 638 if got, want := captured.LastRecipes, []string{"Cooked Recently"}; !slices.Equal(got, want) { 636 639 t.Fatalf("expected only recently cooked recipes in avoid list, got %v", got) 637 640 } 641 + } 642 + 643 + func TestSpin_RendersCachedGenerationStatus(t *testing.T) { 644 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 645 + s := newTestServer(t, withTestCache(cacheStore)) 646 + 647 + hash := "spinner-hash" 648 + status := "Baby we working" 649 + writer := s.statusReader.(*statusStore) 650 + err := writer.SaveGenerationStatus(t.Context(), hash, status) 651 + require.NoError(t, err) 652 + 653 + rr := httptest.NewRecorder() 654 + 655 + s.spin(t.Context(), rr, hash) 656 + 657 + if rr.Code != http.StatusOK { 658 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 659 + } 660 + assert.Contains(t, rr.Body.String(), status) 638 661 } 639 662 640 663 type captureQuestionGenerator struct {
+2 -1
internal/templates/spinner.html
··· 19 19 <div class="mx-auto h-14 w-14 animate-spin rounded-full border-4 border-brand-100 border-t-brand-600" aria-hidden="true"></div> 20 20 <span class="sr-only">Loading</span> 21 21 <h1 class="mt-6 text-2xl font-semibold text-brand-700">Please wait…</h1> 22 - <p class="mt-2 text-sm text-ink-600">We’re putting your recipes together. We’ll check again every {{.RefreshInterval}} seconds.</p> 22 + <p class="mt-2 text-sm text-ink-600">{{.StatusMessage}}</p> 23 + <p class="mt-2 text-sm text-ink-500">We'll check again every {{.RefreshInterval}} seconds.</p> 23 24 <p class="mt-4 text-sm"> 24 25 <a class="font-medium text-brand-600 underline-offset-4 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2" href="">Refresh now</a> 25 26 </p>
+5
internal/templates/templates_test.go
··· 103 103 Style seasons.Style 104 104 ServerSignedIn bool 105 105 RefreshInterval string 106 + StatusMessage string 106 107 }{ 107 108 Style: seasons.GetCurrentStyle(), 108 109 ServerSignedIn: false, 109 110 RefreshInterval: "10", 111 + StatusMessage: "Ingredients are ready. Building your recipes.", 110 112 } 111 113 112 114 var buf bytes.Buffer ··· 120 122 } 121 123 if !strings.Contains(rendered, `const serverSignedIn =`) || !strings.Contains(rendered, `!serverSignedIn && clerkSignedIn`) { 122 124 t.Fatalf("spinner page should pass server sign-in state to Clerk refresh logic, body: %s", rendered) 125 + } 126 + if !strings.Contains(rendered, data.StatusMessage) { 127 + t.Fatalf("spinner page should render status message, body: %s", rendered) 123 128 } 124 129 } 125 130