diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3aefd3c..203d754 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -317,7 +317,7 @@ automatic** (D-021). job (service-only: `POST /jobs` refuses the kind) and answers 202 `{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope: `skills.IdeasScope`, the flag and the normalized list in order), then - grounds its ideas in the model's order with `Grounder.GroundMany`, as + grounds its ideas in the model's order with `Grounder.GroundEach`, as many as are still wanted at a time, until 5 are kept or none are left. An idea no recipe grounds is dropped; with `quick_only`, so is one whose grounded recipe is not a Quick dinner (`ideas.IsQuick`: method @@ -326,10 +326,14 @@ automatic** (D-021). `{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary, method, minutes_total, difficulty, quick, recipe_id, photo_url, source_url, site_name}], dropped}` at each stage (`suggesting`, - `grounding`, `done`) and again each time an idea is kept, so the result - fills as the ideas ground; it may end `done` with no ideas. It fails - with `ai_failed` (the skill or `normalize_recipe` failed; no idea is - made up instead, D-017), `search_not_configured` or `search_failed`. + `grounding`, `done`) and again as each idea is kept, the moment its + grounding finishes, so the result fills idea by idea; the ideas shown + are always in the model's order, an earlier idea that grounds later + taking its place before those shown. It fails with `no_ideas_found` + (every idea was dropped), `ai_failed` (the skill or `normalize_recipe` + failed; no idea is made up instead, D-017), `search_not_configured` or + `search_failed`. Before it fails it publishes its result again with no + ideas, so a failed job shows none, even those kept before the failure. `GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id, name, photo_url, difficulty, method, minutes_total, cuisine, reason, cited_regular_ids, status, dinner_status, decided_at, created_at}], @@ -663,7 +667,8 @@ untitled (the job fails `title_failed`), and stores nothing. The candidates `Ground` would try, without grounding any. Search failures wrap `recipes.ErrSearchFailed` and model provider failures `recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per - item, so one failure never affects its siblings. A recipe is usable only + item, so one failure never affects its siblings; `GroundEach` grounds + the same way but reports each item, one at a time, as it finishes. A recipe is usable only with ingredients, an image and, if given, a positive total time. ## Scheduler diff --git a/internal/ideas/ideas.go b/internal/ideas/ideas.go index 2012a0d..f6460b4 100644 --- a/internal/ideas/ideas.go +++ b/internal/ideas/ideas.go @@ -2,8 +2,9 @@ // of ingredients on hand, ideas_from_ingredients proposes dinners, each is // grounded with the recipe grounding pipeline (dropping those no recipe // grounds), and the ideas kept are reported as a job's result, which fills -// as each idea grounds. Ideas are not stored as dinners: the household -// acts on one later. +// as each idea grounds. A job that keeps no idea, or fails, shows no idea: +// nothing is made up instead (D-017). Ideas are not stored as dinners: the +// household acts on one later. package ideas import ( @@ -12,6 +13,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" @@ -43,10 +45,15 @@ const ( ReasonSearchNotConfigured = "search_not_configured" // ReasonSearchFailed: the search provider failed. ReasonSearchFailed = "search_failed" + // ReasonNoIdeasFound: every idea was dropped, by + // ideas_from_ingredients' rules, because no recipe grounds it, or, for + // a Quick only request, because its grounded recipe is not a Quick + // dinner. + ReasonNoIdeasFound = "no_ideas_found" ) // Reasons are every failure reason of the job. -var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed} +var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound} // InvalidError is a request the household must correct, such as an empty // ingredient list. @@ -153,7 +160,9 @@ type Idea struct { } // Result is the KindFromIngredients result, published as the job goes: -// its stage, the request, the ideas grounded so far in the model's order, +// its stage, the request, the ideas grounded so far in the model's order +// (an idea that grounds sooner than one before it is shown first, and the +// earlier one is put back in its place when it grounds), // and how many ideas were dropped, by ideas_from_ingredients' rules, // because no recipe grounds them, or, for a Quick only request, because // the grounded recipe is not a Quick dinner. @@ -179,103 +188,132 @@ func IsQuick(method string, minutes *int) bool { // most the number still wanted at a time, until skills.IdeasKept are kept // or none are left. An idea no recipe grounds is dropped, and so, for a // Quick only request, is one whose grounded recipe is not a Quick dinner; -// any other grounding failure fails the job, as does -// ideas_from_ingredients failing, with no idea made up instead. The -// result is published at each stage and again as each idea is kept, so it -// fills as the ideas ground. The job may end done with no ideas, when -// every one was dropped. +// when every idea is dropped the job fails with ReasonNoIdeasFound. Any +// other grounding failure fails the job, as does ideas_from_ingredients +// failing, with no idea made up instead. The result is published at each +// stage and again as each idea is kept, so it fills as the ideas ground. +// Before the job fails, its result is published again with no ideas, so a +// failed job never shows ideas kept before the failure. func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) { res, err := s.fill(ctx, p, publish) if err != nil { + if ctx.Err() != nil { + return Result{}, err + } var failure *jobs.Failure - if ctx.Err() == nil && errors.As(err, &failure) { + if errors.As(err, &failure) { // Only the code is logged: the error may quote a model's or // search provider's response. s.logger.InfoContext(ctx, "ideas from ingredients failed", "reason", failure.Code) } + if len(res.Ideas) > 0 { + res.Ideas = []Idea{} + if perr := publish(res); perr != nil { + return Result{}, fmt.Errorf("ideas: clear failed job's ideas: %w", perr) + } + } return Result{}, err } return res, nil } // fill does run's work, returning a *jobs.Failure for a failure the -// household may see. +// household may see. On failure it returns the result last published with +// the error, so run can clear its ideas. func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) { res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}} if err := publish(res); err != nil { - return Result{}, err + return res, err } in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly} out, err := s.generate(ctx, skills.IdeasScope(in), in) if err != nil { if ctx.Err() != nil { - return Result{}, ctx.Err() + return res, ctx.Err() } - return Result{}, &jobs.Failure{Code: ReasonAIFailed} + return res, &jobs.Failure{Code: ReasonAIFailed} } res.Stage = StageGrounding res.Dropped = len(out.Dropped) if err := publish(res); err != nil { - return Result{}, err + return res, err } - left := out.Ideas - for len(res.Ideas) < skills.IdeasKept && len(left) > 0 { - round := left[:min(skills.IdeasKept-len(res.Ideas), len(left))] - left = left[len(round):] - results, err := s.ground(ctx, round) - if err != nil { - return Result{}, err - } - for i, r := range results { - if r == nil { + // order[k] is the index in out.Ideas of res.Ideas[k], so each idea is + // put in its place in the model's order as it grounds. + var order []int + for next := 0; len(res.Ideas) < skills.IdeasKept && next < len(out.Ideas); { + first := next + round := out.Ideas[first:min(first+skills.IdeasKept-len(res.Ideas), len(out.Ideas))] + next += len(round) + err := s.ground(ctx, round, func(i int, gr *recipes.Grounded) error { + if gr == nil { res.Dropped++ - continue + return nil } - idea := ideaOf(round[i], *r) + idea := ideaOf(round[i], *gr) if p.QuickOnly && !idea.Quick { res.Dropped++ - continue + return nil } - res.Ideas = append(res.Ideas, idea) - if err := publish(res); err != nil { - return Result{}, err + at := len(order) + for at > 0 && order[at-1] > first+i { + at-- } + order = slices.Insert(order, at, first+i) + res.Ideas = slices.Insert(res.Ideas, at, idea) + return publish(res) + }) + if err != nil { + return res, err } } + if len(res.Ideas) == 0 { + return res, &jobs.Failure{Code: ReasonNoIdeasFound} + } res.Stage = StageDone return res, nil } -// ground grounds ideas by their search queries and returns one result per -// idea, in order: its grounding, or nil when no recipe grounds it. Any -// other failure fails the job. -func (s *Service) ground(ctx context.Context, ideas []skills.Idea) ([]*recipes.Grounded, error) { +// ground grounds ideas by their search queries and calls kept, one call at +// a time, as each idea grounds, with its index in ideas and its +// grounding, or nil when no recipe grounds it. Any other grounding +// failure, or kept failing, stops the groundings still running and is +// returned, as a *jobs.Failure when the household may see it. +func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i int, gr *recipes.Grounded) error) error { + groundCtx, cancel := context.WithCancel(ctx) + defer cancel() reqs := make([]recipes.GroundRequest, len(ideas)) for i, idea := range ideas { reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} } - results := s.grounder.GroundMany(ctx, reqs) - if err := ctx.Err(); err != nil { - return nil, err - } - out := make([]*recipes.Grounded, len(results)) - for i, r := range results { + var failed error + s.grounder.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) { + if failed != nil { + return + } var ungroundable *recipes.ErrUngroundable switch { case r.Err == nil: - out[i] = &r.Grounded + failed = kept(i, &r.Grounded) case errors.As(r.Err, &ungroundable): + failed = kept(i, nil) case errors.Is(r.Err, recipes.ErrSearchNotConfigured): - return nil, &jobs.Failure{Code: ReasonSearchNotConfigured} + failed = &jobs.Failure{Code: ReasonSearchNotConfigured} case errors.Is(r.Err, recipes.ErrSearchFailed): - return nil, &jobs.Failure{Code: ReasonSearchFailed} + failed = &jobs.Failure{Code: ReasonSearchFailed} case errors.Is(r.Err, recipes.ErrNormalizeFailed): - return nil, &jobs.Failure{Code: ReasonAIFailed} + failed = &jobs.Failure{Code: ReasonAIFailed} default: - return nil, fmt.Errorf("ideas: ground %q: %w", ideas[i].Name, r.Err) + failed = fmt.Errorf("ideas: ground %q: %w", ideas[i].Name, r.Err) } + if failed != nil { + cancel() + } + }) + if err := ctx.Err(); err != nil { + return err } - return out, nil + return failed } // ideaOf is idea as grounded by gr. diff --git a/internal/ideas/ideas_test.go b/internal/ideas/ideas_test.go index 698324b..7523fbc 100644 --- a/internal/ideas/ideas_test.go +++ b/internal/ideas/ideas_test.go @@ -131,6 +131,16 @@ type recorder struct { published []Result } +// latest returns the last result published. +func (r *recorder) latest() (Result, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.published) == 0 { + return Result{}, false + } + return r.published[len(r.published)-1], true +} + func (r *recorder) publish(res Result) error { r.mu.Lock() defer r.mu.Unlock() @@ -154,6 +164,17 @@ func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, [ return res, rec.published, err } +// within reports whether every name in sub appears in list, in list's +// order. +func within(sub, list []string) bool { + for _, name := range list { + if len(sub) > 0 && sub[0] == name { + sub = sub[1:] + } + } + return len(sub) == 0 +} + func names(list []Idea) []string { out := []string{} for _, i := range list { @@ -214,9 +235,13 @@ func TestResultFillsAsEachIdeaGrounds(t *testing.T) { if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) { t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages) } + // Ideas ground concurrently, so one may appear before an earlier one, + // but each publish keeps every idea already shown, in the model's + // order. for i := 3; i < len(published); i++ { - if !reflect.DeepEqual(published[i].Ideas[:i-2], published[i-1].Ideas) { - t.Errorf("publish %d changed ideas already published", i+1) + prev, cur := names(published[i-1].Ideas), names(published[i].Ideas) + if !within(prev, cur) || !within(cur, want) { + t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want) } } @@ -342,15 +367,122 @@ func TestFailures(t *testing.T) { } } -func TestEveryIdeaUngroundableEndsDoneEmpty(t *testing.T) { +func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) { e := newEnv(t) - e.reply(idea("Mystery Stew", "stovetop", 30, "rice")) - res, _, err := e.run(t, pantry, false) + for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} { + e.recipe(t, name, name, 35) + } + // The first idea's search waits, so the two after it ground first. + gate := make(chan struct{}) + e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate} + e.reply( + idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"), + idea("Fried Rice", "stovetop", 20, "rice"), + idea("Black Bean Soup", "stovetop", 40, "black beans"), + ) + ings, err := skills.CleanIngredients(pantry) if err != nil { t.Fatal(err) } - if res.Stage != StageDone || len(res.Ideas) != 0 || res.Dropped != 1 { - t.Fatalf("result = %+v, want done with none and 1 dropped", res) + var rec recorder + type outcome struct { + res Result + err error + } + done := make(chan outcome, 1) + go func() { + res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish) + done <- outcome{res, err} + }() + + deadline := time.Now().Add(10 * time.Second) + for { + if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 { + if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) { + t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got) + } + if latest.Stage != StageGrounding { + t.Fatalf("stage = %s, want grounding", latest.Stage) + } + break + } + if time.Now().After(deadline) { + t.Fatal("the ideas that grounded were not shown while the first idea was still grounding") + } + time.Sleep(5 * time.Millisecond) + } + close(gate) + out := <-done + if out.err != nil { + t.Fatal(out.err) + } + want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} + if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone { + t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want) + } +} + +func TestNoIdeaKeptFailsTheJob(t *testing.T) { + for _, tc := range []struct { + name string + quickOnly bool + setup func(t *testing.T, e *env) + }{ + {"no recipe grounds any", false, func(t *testing.T, e *env) { + e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice")) + }}, + {"no grounded recipe is quick", true, func(t *testing.T, e *env) { + e.recipe(t, "Roast Chicken", "Roast Chicken", 90) + e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs")) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + e := newEnv(t) + tc.setup(t, e) + _, published, err := e.run(t, pantry, tc.quickOnly) + var failure *jobs.Failure + if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound { + t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound) + } + for _, p := range published { + if len(p.Ideas) != 0 || p.Stage == StageDone { + t.Fatalf("a job that kept no idea published %+v", p) + } + } + }) + } +} + +func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) { + e := newEnv(t) + kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"} + for _, name := range kept { + e.recipe(t, name, name, 35) + } + // The first round keeps four ideas; the second round's search fails. + e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")} + e.reply( + idea("Burrito Bowls", "stovetop", 35, "rice"), + idea("Mystery Stew", "stovetop", 35, "rice"), + idea("Fried Rice", "stovetop", 20, "rice"), + idea("Black Bean Soup", "stovetop", 40, "black beans"), + idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"), + idea("Rice Pilaf", "stovetop", 30, "rice"), + ) + _, published, err := e.run(t, pantry, false) + var failure *jobs.Failure + if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed { + t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed) + } + most := 0 + for _, p := range published { + most = max(most, len(p.Ideas)) + } + if most != len(kept) { + t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept)) + } + if last := published[len(published)-1]; len(last.Ideas) != 0 { + t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas)) } } diff --git a/internal/recipes/ground.go b/internal/recipes/ground.go index 6b14436..3a69404 100644 --- a/internal/recipes/ground.go +++ b/internal/recipes/ground.go @@ -292,7 +292,22 @@ type GroundResult struct { // affect the others. func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []GroundResult { results := make([]GroundResult, len(reqs)) + g.GroundEach(ctx, reqs, func(i int, r GroundResult) { results[i] = r }) + return results +} + +// GroundEach grounds each request as GroundMany does, but calls done with +// each item's index and result as soon as that item finishes, one call at +// a time. It returns once every item has finished and every call has +// returned. +func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done func(i int, r GroundResult)) { slots := make(chan struct{}, GroundConcurrency) + var mu sync.Mutex + report := func(i int, r GroundResult) { + mu.Lock() + defer mu.Unlock() + done(i, r) + } var wg sync.WaitGroup for i, req := range reqs { wg.Add(1) @@ -301,16 +316,15 @@ func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []Groun select { case slots <- struct{}{}: case <-ctx.Done(): - results[i] = GroundResult{Err: ctx.Err()} + report(i, GroundResult{Err: ctx.Err()}) return } - defer func() { <-slots }() gr, err := g.Ground(ctx, req.DishName, req.Query) - results[i] = GroundResult{Grounded: gr, Err: err} + <-slots + report(i, GroundResult{Grounded: gr, Err: err}) }() } wg.Wait() - return results } // stored returns the grounding of the recipe stored for sourceURL, and diff --git a/internal/recipes/recipestest/recipestest.go b/internal/recipes/recipestest/recipestest.go index 9d22dfa..061651f 100644 --- a/internal/recipes/recipestest/recipestest.go +++ b/internal/recipes/recipestest/recipestest.go @@ -23,12 +23,16 @@ import ( ) // Search is a recipes.SearchSource and recipes.SearchProvider answering -// each query from Results. Err, when set, fails every search. Gate, when -// set, makes every search wait for it to close. +// each query from Results. Err, when set, fails every search, and Fail +// fails the searches for its queries. Gate, when set, makes every search +// wait for it to close, and Gates makes the searches for its queries wait +// for theirs. type Search struct { Results map[string][]recipes.Candidate Err error + Fail map[string]error Gate chan struct{} + Gates map[string]chan struct{} mu sync.Mutex queries []string @@ -40,9 +44,12 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can s.mu.Lock() s.queries = append(s.queries, query) s.mu.Unlock() - if s.Gate != nil { + for _, gate := range []chan struct{}{s.Gate, s.Gates[query]} { + if gate == nil { + continue + } select { - case <-s.Gate: + case <-gate: case <-ctx.Done(): return nil, ctx.Err() } @@ -50,6 +57,9 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can if s.Err != nil { return nil, s.Err } + if err := s.Fail[query]; err != nil { + return nil, err + } c := s.Results[query] return c[:min(n, len(c))], nil } diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go index d17edfe..1a4949d 100644 --- a/internal/server/ideas_test.go +++ b/internal/server/ideas_test.go @@ -3,9 +3,12 @@ package server import ( "context" "encoding/json" + "errors" + "fmt" "io" "log/slog" "net/http" + "strings" "testing" "testing/fstest" "time" @@ -164,6 +167,61 @@ func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) { } } +func TestFailedIdeasJobShowsNoIdeas(t *testing.T) { + t.Parallel() + idea := func(name string) string { + return fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name) + } + for _, tc := range []struct { + name string + // grounds are the ideas a recipe grounds; the others ground none. + grounds []string + ideas []string + fail map[string]error + code string + }{ + {"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"}, + {"a later round fails after ideas were shown", + []string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"}, + []string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"}, + map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + model, normalize := &llm.Fake{}, &llm.Fake{} + search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: tc.fail} + pages := &recipestest.Pages{Bodies: map[string][]byte{}} + images := &recipestest.Images{JPEGs: map[string][]byte{}} + for i, name := range tc.grounds { + page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i) + search.Results[name] = []recipes.Candidate{{URL: page}} + pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25) + images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1)) + normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + } + list := make([]string, len(tc.ideas)) + for i, name := range tc.ideas { + list[i] = idea(name) + } + js := `{"ideas":[` + strings.Join(list, ",") + `]}` + model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) + h, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + + rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + job := waitJob(t, h, body["jobId"].(string)) + if job["status"] != "failed" || job["error"] != tc.code { + t.Fatalf("GET job = %v, want failed with %s", job, tc.code) + } + if res, _ := job["result"].(map[string]any); res == nil || len(res["ideas"].([]any)) != 0 { + t.Fatalf("failed job result = %v, want no ideas", job["result"]) + } + }) + } +} + func TestJobsEndpointRefusesIdeasKind(t *testing.T) { t.Parallel() h, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)