ai cooking
0
fork

Configure Feed

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

Adminpage for critiques (#481)

* show critiques

* parallel fetch

* review comments

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
dd289ab3 9fec3e6f

+288 -14
+1
cmd/careme/web.go
··· 90 90 91 91 adminMux := http.NewServeMux() 92 92 adminMux.Handle("/users", users.AdminUsersPage(userStorage)) 93 + adminMux.Handle("/critiques", recipes.AdminCritiquesPage(cache)) 93 94 ingredientsHandler := ingredients.NewHandler(cache) 94 95 ingredientsHandler.Register(adminMux) 95 96 appRoutes.Handle("/admin/", admin.New(cfg, authClient).Enforce(http.StripPrefix("/admin", adminMux)))
+6 -12
internal/ai/critique.go
··· 49 49 CritiquedAt time.Time `json:"critiqued_at,omitempty" jsonschema:"-"` 50 50 } 51 51 52 - type Critiquer struct { 52 + type critiquer struct { 53 53 apiKey string 54 54 model string 55 55 schema map[string]any 56 56 } 57 57 58 - func NewCritiquer(apiKey, model string) *Critiquer { 58 + func NewCritiquer(apiKey, model string) *critiquer { 59 59 model = strings.TrimSpace(model) 60 60 if model == "" { 61 61 model = defaultGeminiCritiqueModel 62 62 } 63 - return &Critiquer{ 63 + return &critiquer{ 64 64 apiKey: strings.TrimSpace(apiKey), 65 65 model: model, 66 66 schema: recipeCritiqueJSONSchema(), 67 67 } 68 68 } 69 69 70 - func (c *Critiquer) Ready(ctx context.Context) error { 71 - if c == nil || c.apiKey == "" { 72 - return fmt.Errorf("gemini critique client is not configured") 73 - } 70 + func (c *critiquer) Ready(ctx context.Context) error { 74 71 client, err := c.newClient(ctx) 75 72 if err != nil { 76 73 return err ··· 93 90 */ 94 91 } 95 92 96 - func (c *Critiquer) CritiqueRecipe(ctx context.Context, recipe Recipe) (*RecipeCritique, error) { 97 - if c == nil || c.apiKey == "" { 98 - return nil, fmt.Errorf("gemini critique client is not configured") 99 - } 93 + func (c *critiquer) CritiqueRecipe(ctx context.Context, recipe Recipe) (*RecipeCritique, error) { 100 94 prompt, err := buildRecipeCritiquePrompt(recipe) 101 95 if err != nil { 102 96 return nil, fmt.Errorf("failed to build recipe critique prompt: %w", err) ··· 131 125 return critique, nil 132 126 } 133 127 134 - func (c *Critiquer) newClient(ctx context.Context) (*genai.Client, error) { 128 + func (c *critiquer) newClient(ctx context.Context) (*genai.Client, error) { 135 129 client, err := genai.NewClient(ctx, &genai.ClientConfig{ 136 130 APIKey: c.apiKey, 137 131 Backend: genai.BackendGeminiAPI,
+154
internal/recipes/admin_page.go
··· 1 + package recipes 2 + 3 + import ( 4 + "context" 5 + "html/template" 6 + "log/slog" 7 + "net/http" 8 + "sort" 9 + 10 + "careme/internal/ai" 11 + "careme/internal/cache" 12 + "careme/internal/parallelism" 13 + 14 + "github.com/samber/lo" 15 + ) 16 + 17 + type adminCritiqueView struct { 18 + RecipeTitle string 19 + RecipeURL string 20 + ai.RecipeCritique 21 + } 22 + 23 + var adminCritiquesPageTmpl = template.Must(template.New("admin-critiques").Parse(`<!doctype html> 24 + <html lang="en"> 25 + <head> 26 + <meta charset="utf-8" /> 27 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 28 + <title>Admin Recipe Critiques</title> 29 + </head> 30 + <body> 31 + <nav> 32 + <a href="/admin/users">Users</a> | 33 + <a href="/admin/critiques">Recipe Critiques</a> 34 + </nav> 35 + <h1>Recipe Critiques</h1> 36 + <p>Total critiques: {{len .Critiques}}</p> 37 + <table border="1" cellpadding="6" cellspacing="0"> 38 + <thead> 39 + <tr> 40 + <th>Recipe</th> 41 + <th>Score</th> 42 + <th>Summary</th> 43 + <th>Details</th> 44 + </tr> 45 + </thead> 46 + <tbody> 47 + {{range .Critiques}} 48 + <tr> 49 + <td> 50 + <a href="{{.RecipeURL}}">{{.RecipeTitle}}</a> 51 + </td> 52 + <td>{{.OverallScore}}/10</td> 53 + <td> 54 + {{.Summary}} 55 + </td> 56 + <td> 57 + <details> 58 + <summary>Open critique</summary> 59 + {{if .Strengths}} 60 + <p><strong>Strengths</strong></p> 61 + <ul> 62 + {{range .Strengths}} 63 + <li>{{.}}</li> 64 + {{end}} 65 + </ul> 66 + {{end}} 67 + {{if .Issues}} 68 + <p><strong>Issues</strong></p> 69 + <ul> 70 + {{range .Issues}} 71 + <li>{{.Severity}} / {{.Category}}: {{.Detail}}</li> 72 + {{end}} 73 + </ul> 74 + {{end}} 75 + {{if .SuggestedFixes}} 76 + <p><strong>Suggested fixes</strong></p> 77 + <ul> 78 + {{range .SuggestedFixes}} 79 + <li>{{.}}</li> 80 + {{end}} 81 + </ul> 82 + {{end}} 83 + </details> 84 + </td> 85 + </tr> 86 + {{end}} 87 + </tbody> 88 + </table> 89 + </body> 90 + </html>`)) 91 + 92 + func AdminCritiquesPage(c cache.ListCache) http.Handler { 93 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 94 + if r.Method != http.MethodGet && r.Method != http.MethodHead { 95 + w.WriteHeader(http.StatusMethodNotAllowed) 96 + return 97 + } 98 + 99 + // this won't last long till its too big. 100 + hashes, err := c.List(r.Context(), recipeCritiquesCachePrefix, "") 101 + if err != nil { 102 + slog.ErrorContext(r.Context(), "failed to list recipe critiques for admin page", "error", err) 103 + http.Error(w, "unable to load recipe critiques", http.StatusInternalServerError) 104 + return 105 + } 106 + 107 + views, err := loadAdminCritiqueViews(r.Context(), c, hashes) 108 + if err != nil { 109 + slog.ErrorContext(r.Context(), "failed to load recipe critiques for admin page", "error", err) 110 + http.Error(w, "unable to load recipe critiques", http.StatusInternalServerError) 111 + return 112 + } 113 + views = lo.Compact(views) 114 + sort.Slice(views, func(i, j int) bool { 115 + return views[i].CritiquedAt.After(views[j].CritiquedAt) 116 + }) 117 + 118 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 119 + w.Header().Set("X-Content-Type-Options", "nosniff") 120 + if err := adminCritiquesPageTmpl.Execute(w, struct { 121 + Critiques []*adminCritiqueView 122 + }{Critiques: views}); err != nil { 123 + slog.ErrorContext(r.Context(), "failed to render admin recipe critiques page", "error", err) 124 + http.Error(w, "unable to render recipe critiques", http.StatusInternalServerError) 125 + return 126 + } 127 + }) 128 + } 129 + 130 + func loadAdminCritiqueViews(ctx context.Context, c cache.Cache, hashes []string) ([]*adminCritiqueView, error) { 131 + rio := IO(c) 132 + views, err := parallelism.MapWithErrors(hashes, func(hash string) (*adminCritiqueView, error) { 133 + view := adminCritiqueView{ 134 + RecipeURL: "/recipe/" + hash, 135 + } 136 + 137 + critique, err := rio.CritiqueFromCache(ctx, hash) 138 + if err != nil { 139 + return nil, err 140 + } 141 + view.RecipeCritique = *critique 142 + 143 + recipe, err := rio.SingleFromCache(ctx, hash) 144 + if err != nil { 145 + slog.ErrorContext(ctx, "failed to load recipe for admin critiques page", "hash", hash, "error", err) 146 + view.RecipeTitle = "Unknown recipe" 147 + } else { 148 + view.RecipeTitle = recipe.Title 149 + } 150 + 151 + return &view, nil 152 + }) 153 + return views, err 154 + }
+119
internal/recipes/admin_page_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "careme/internal/ai" 11 + "careme/internal/cache" 12 + ) 13 + 14 + func TestAdminCritiquesPageRendersNewestFirst(t *testing.T) { 15 + t.Parallel() 16 + 17 + fc := cache.NewFileCache(t.TempDir()) 18 + rio := IO(fc) 19 + 20 + recipes := []ai.Recipe{ 21 + { 22 + Title: "Spring Chicken", 23 + Description: "Bright and quick.", 24 + CookTime: "35 minutes", 25 + CostEstimate: "$18-24", 26 + Ingredients: []ai.Ingredient{ 27 + {Name: "Chicken thighs", Quantity: "2 lb", Price: "$9.99"}, 28 + }, 29 + Instructions: []string{"Season the chicken.", "Roast until cooked through."}, 30 + Health: "Balanced dinner.", 31 + DrinkPairing: "Chardonnay", 32 + }, 33 + { 34 + Title: "Herby Beans", 35 + Description: "Comforting and savory.", 36 + CookTime: "25 minutes", 37 + CostEstimate: "$12-16", 38 + Ingredients: []ai.Ingredient{ 39 + {Name: "Cannellini beans", Quantity: "2 cans", Price: "$4.99"}, 40 + }, 41 + Instructions: []string{"Warm the beans.", "Finish with herbs."}, 42 + Health: "Fiber rich.", 43 + DrinkPairing: "Pinot Grigio", 44 + }, 45 + } 46 + if err := rio.SaveRecipes(t.Context(), recipes, "origin-hash"); err != nil { 47 + t.Fatalf("save recipes: %v", err) 48 + } 49 + 50 + newestHash := recipes[0].ComputeHash() 51 + olderHash := recipes[1].ComputeHash() 52 + 53 + if err := rio.SaveCritique(t.Context(), newestHash, &ai.RecipeCritique{ 54 + SchemaVersion: "recipe-critique-v1", 55 + OverallScore: 9, 56 + Summary: "Strong weeknight draft.", 57 + Strengths: []string{"clear sequencing", "good contrast"}, 58 + Issues: []ai.RecipeCritiqueIssue{{Severity: "high", Category: "timing", Detail: "Rest the chicken before slicing."}}, 59 + SuggestedFixes: []string{"Add a two minute resting step before plating."}, 60 + Model: "gemini-3.1-pro-preview", 61 + CritiquedAt: time.Date(2026, time.April, 13, 20, 15, 0, 0, time.UTC), 62 + }); err != nil { 63 + t.Fatalf("save newest critique: %v", err) 64 + } 65 + if err := rio.SaveCritique(t.Context(), olderHash, &ai.RecipeCritique{ 66 + SchemaVersion: "recipe-critique-v1", 67 + OverallScore: 6, 68 + Summary: "Needs more brightness.", 69 + Strengths: []string{"budget friendly"}, 70 + Issues: []ai.RecipeCritiqueIssue{{Severity: "medium", Category: "flavor", Detail: "Add acid near the end."}}, 71 + SuggestedFixes: []string{"Finish with lemon juice."}, 72 + Model: "gemini-3.1-pro-preview", 73 + CritiquedAt: time.Date(2026, time.April, 11, 14, 0, 0, 0, time.UTC), 74 + }); err != nil { 75 + t.Fatalf("save older critique: %v", err) 76 + } 77 + 78 + req := httptest.NewRequest(http.MethodGet, "/critiques", nil) 79 + rr := httptest.NewRecorder() 80 + 81 + AdminCritiquesPage(fc).ServeHTTP(rr, req) 82 + 83 + if rr.Code != http.StatusOK { 84 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) 85 + } 86 + if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "text/html") { 87 + t.Fatalf("content-type = %q, want text/html", got) 88 + } 89 + 90 + body := rr.Body.String() 91 + for _, want := range []string{ 92 + "Spring Chicken", 93 + "Herby Beans", 94 + "Strong weeknight draft.", 95 + "Needs more brightness.", 96 + "Rest the chicken before slicing.", 97 + "Finish with lemon juice.", 98 + "/recipe/" + newestHash, 99 + } { 100 + if !strings.Contains(body, want) { 101 + t.Fatalf("response body missing %q: %s", want, body) 102 + } 103 + } 104 + } 105 + 106 + func TestAdminCritiquesPageMethodNotAllowed(t *testing.T) { 107 + t.Parallel() 108 + 109 + fc := cache.NewFileCache(t.TempDir()) 110 + 111 + req := httptest.NewRequest(http.MethodPost, "/critiques", nil) 112 + rr := httptest.NewRecorder() 113 + 114 + AdminCritiquesPage(fc).ServeHTTP(rr, req) 115 + 116 + if rr.Code != http.StatusMethodNotAllowed { 117 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusMethodNotAllowed) 118 + } 119 + }
+4 -2
internal/recipes/generator.go
··· 247 247 if err := g.aiClient.Ready(ctx); err != nil { 248 248 return err 249 249 } 250 - if err := g.critiquer.Ready(ctx); err != nil { 251 - return fmt.Errorf("gemini critique client not ready: %w", err) 250 + if g.critiquer != nil { 251 + if err := g.critiquer.Ready(ctx); err != nil { 252 + return fmt.Errorf("gemini critique client not ready: %w", err) 253 + } 252 254 } 253 255 return nil 254 256 }
+4
internal/users/admin_page.go
··· 31 31 <title>Admin Users</title> 32 32 </head> 33 33 <body> 34 + <nav> 35 + <a href="/admin/users">Users</a> | 36 + <a href="/admin/critiques">Recipe Critiques</a> 37 + </nav> 34 38 <h1>Users</h1> 35 39 <p>Total users: {{len .Users}}</p> 36 40 <table border="1" cellpadding="6" cellspacing="0">