Add quick_ideas skill and job for Quick dinners (Feature 18)
CI / ci (push) Successful in 5m36s
CI / ci (pull_request) Successful in 5m34s

quick_ideas v1 suggests Quick dinners of one kind, crockpot or under30,
from the household context, never returning a name already shown: the
exclude list is checked against normalized names with harness.Similar.
POST /api/v1/ideas/quick {kind, exclude[]} validates both fields (no
default kind) and answers 202 {jobId}; the quick_ideas job asks for 12
ideas, grounds them through the same GroundEach rounds as
ideas_from_ingredients and keeps up to 6 whose grounded recipe is of the
kind (crockpot: method crockpot; under30: minutes_total <= 30), so the
model's estimate never decides. Fewer survivors make a shorter page,
never a padded one.

The grounding loop, failure clearing and failed-result view are now
shared generic helpers used by both idea jobs rather than a second copy.

Maestro task 243a6812-f52b-4215-a4a2-8eb599914f7e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:25:41 -04:00
co-authored by Claude Opus 5.5
parent 3c58e0c7f7
commit 290e3d3cd3
18 changed files with 1800 additions and 110 deletions
+43 -1
View File
@@ -8,7 +8,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
)
// maxIdeasBody bounds a POST /ideas/from-ingredients body.
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
const maxIdeasBody = 16 << 10
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
@@ -51,3 +51,45 @@ func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.Hand
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
// ideasQuick is the POST /ideas/quick body. Both fields are required:
// exclude is [] on a first page.
type ideasQuick struct {
Kind *string `json:"kind"`
Exclude []string `json:"exclude"`
}
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
// crockpot or under30, none named in exclude, and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
// missing or unknown kind, or an exclude list the household must correct,
// is 400.
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
switch {
case body.Kind == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
return
case body.Exclude == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
return
}
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
var invalid *ideas.InvalidError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
return
case err != nil:
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
+12 -5
View File
@@ -18,6 +18,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -28,13 +29,16 @@ import (
const ideasPath = "/api/v1/ideas/from-ingredients"
// newIdeasRouter returns a router whose ideas service runs
// ideas_from_ingredients through the real harness with the scripted model
// and grounds with fake search, pages and images and the scripted
// normalize, with its job runner and store. The runner is started when
// start is set.
// ideas_from_ingredients and quick_ideas through the real harness with the
// scripted model and grounds with fake search, pages and images and the
// scripted normalize, with its job runner and store, whose household
// settings are seeded. The runner is started when start is set.
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
@@ -54,7 +58,10 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
svc, err := ideas.NewService(g, generate, runner, logger)
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
return skills.QuickIdeas(ctx, cfg, scope, in)
}
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
if err != nil {
t.Fatal(err)
}
+181
View File
@@ -0,0 +1,181 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
const quickPath = "/api/v1/ideas/quick"
// quickRecipe makes the search for name find a page titled title with a
// total time of minutes, and scripts the normalize_recipe reply grounding
// it takes. "Slow Cooker" in title grounds the method as crockpot.
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
search.Results[name] = []recipes.Candidate{{URL: page}}
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
// quickReply scripts one quick_ideas reply holding the ideas named, each
// searched by its name.
func quickReply(model *llm.Fake, ideas ...[3]any) {
list := make([]string, len(ideas))
for i, idea := range ideas {
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
}
func TestPostIdeasQuickRefusesBadRequests(t *testing.T) {
t.Parallel()
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
for _, tc := range []struct{ body, message string }{
{`{"exclude": []}`, "kind: required"},
{`{"kind": "crockpot"}`, "exclude: required"},
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
} {
rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
continue
}
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
}
}
}
// The job answers 202 at once and its result fills idea by idea: one
// idea is shown while another still grounds. The kind is checked against
// the grounded recipe and excluded names never come back.
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
gate := make(chan struct{})
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
quickReply(model,
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
[3]any{"Chicken Fried Rice", "stovetop", 20},
[3]any{"Beef Stroganoff", "stovetop", 25},
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id, _ := body["jobId"].(string)
if id == "" || len(body) != 1 {
t.Fatalf("POST answered %v, want only a jobId", body)
}
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
deadline := time.Now().Add(10 * time.Second)
for {
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
if res, _ := job["result"].(map[string]any); res != nil {
if list, _ := res["ideas"].([]any); len(list) == 1 {
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
t.Fatalf("partial job = %v", job)
}
break
}
}
if time.Now().After(deadline) {
t.Fatal("no idea was shown while another still grounded")
}
time.Sleep(5 * time.Millisecond)
}
close(gate)
job := waitJob(t, h, id)
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
t.Fatalf("GET job = %v", job)
}
res := job["result"].(map[string]any)
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
}
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
t.Errorf("result exclude = %s", ex)
}
var names []string
for _, i := range res["ideas"].([]any) {
names = append(names, i.(map[string]any)["name"].(string))
}
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
t.Fatalf("ideas = %q, want %q", names, want)
}
got := res["ideas"].([]any)[1].(map[string]any)
for key, want := range map[string]any{
"method": "stovetop",
"minutes_total": 30.0,
"site_name": "R Kitchen",
"source_url": "https://r.example/garlic-shrimp-pasta",
} {
if got[key] != want {
t.Errorf("idea %s = %v, want %v", key, got[key], want)
}
}
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
}
}
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
for i := range 5 {
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
}
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
}
func TestJobsEndpointRefusesQuickKind(t *testing.T) {
t.Parallel()
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
}
}
+2 -1
View File
@@ -57,7 +57,7 @@ type RouteMounter interface {
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; ideasSvc serves POST
// /api/v1/ideas/from-ingredients; runner serves
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
@@ -164,6 +164,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+5 -2
View File
@@ -192,7 +192,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
}
// newTestIdeas returns an ideas service with its own unstarted job runner
// and a generator that always fails.
// and generators that always fail.
func newTestIdeas(t *testing.T) *ideas.Service {
t.Helper()
st := newTestStore(t)
@@ -205,7 +205,10 @@ func newTestIdeas(t *testing.T) *ideas.Service {
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
return skills.Ideas{}, errors.New("no model in this test")
}
svc, err := ideas.NewService(g, fail, runner, logger)
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
return skills.QuickIdeaList{}, errors.New("no model in this test")
}
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
if err != nil {
t.Fatal(err)
}