Files
orchestrator-agent 47a5b5cb89
CI / ci (push) Successful in 6m12s
CI / ci (pull_request) Successful in 5m47s
Feature 18: Cook From What I Have and Quick Dinners (#155)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 10:38:36 -04:00

576 lines
19 KiB
Go

package ideas
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// pantry is the household's ingredient list.
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
type env struct {
svc *Service
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
// model answers ideas_from_ingredients and quick_ideas; normalize
// answers normalize_recipe while grounding.
model *llm.Fake
normalize *llm.Fake
// fail, when set, fails ideas_from_ingredients and quick_ideas without
// a model call.
fail error
st *store.Store
}
// newEnv returns an ideas service over a migrated store with the
// household's settings seeded and an unstarted
// job runner. It finds ideas through the real harness and
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
// grounds with fake
// search, pages and images and the scripted e.normalize.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
dataDir := t.TempDir()
st, err := store.Open(ctx, dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &env{
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
model: &llm.Fake{},
normalize: &llm.Fake{},
st: st,
}
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
Store: st,
Guard: guard,
}
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
if e.fail != nil {
return skills.Ideas{}, e.fail
}
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
if e.fail != nil {
return skills.QuickIdeaList{}, e.fail
}
return skills.QuickIdeas(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
e.runner, err = jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// recipe makes the search for name find one page that grounds it, titled
// title, with a total time of minutes, and scripts the normalize_recipe
// reply grounding it takes. The page's total time is the grounded
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
// whatever the reply says.
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
e.search.Results[name] = []recipes.Candidate{{URL: page}}
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
// idea renders one idea of an ideas_from_ingredients reply whose search
// query is its name.
func idea(name, method string, minutes int, uses ...string) string {
u, _ := json.Marshal(append([]string{}, uses...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, minutes, method, name)
}
// reply scripts one ideas_from_ingredients reply holding ideas.
func (e *env) reply(ideas ...string) {
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// recorder collects every result a job publishes.
type recorder struct {
mu sync.Mutex
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()
// The result's slices are copied, as the runner stores each publish
// as JSON at once.
res.Ideas = append([]Idea(nil), res.Ideas...)
r.published = append(r.published, res)
return nil
}
// run runs the job's handler directly on ingredients, recording each
// publish.
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
t.Helper()
ings, err := skills.CleanIngredients(ingredients)
if err != nil {
t.Fatal(err)
}
var rec recorder
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
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 {
out = append(out, i.Name)
}
return out
}
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
e := newEnv(t)
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
e.recipe(t, name, name, 35)
}
// Eight ideas: two no recipe grounds, so the sixth and seventh are
// grounded in a second round, and the eighth never is.
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
idea("Mystery Stew", "stovetop", 35, "rice"),
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
idea("Unknown Casserole", "oven", 50, "rice"),
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
idea("Rice Pilaf", "stovetop", 30, "rice"),
idea("Bean Salad", "other", 15, "black beans"),
)
res, published, err := e.run(t, pantry, false)
if err != nil {
t.Fatal(err)
}
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
t.Fatalf("ideas = %q, want %q", got, want)
}
if res.Stage != StageDone || res.Dropped != 2 {
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
}
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
}
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
t.Errorf("searched %q, want the first seven ideas only", got)
}
// Each publish: the stage, then one more idea at a time, each idea
// grounded when it appears.
var stages []string
var counts []int
for _, p := range published {
stages = append(stages, p.Stage)
counts = append(counts, len(p.Ideas))
for _, i := range p.Ideas {
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
}
}
}
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
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++ {
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)
}
}
first := res.Ideas[0]
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
t.Errorf("first idea = %+v", first)
}
}
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
for _, quickOnly := range []bool{true, false} {
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
e := newEnv(t)
// The model estimates every idea quick; the grounded recipes
// decide.
e.recipe(t, "Fried Rice", "Fried Rice", 25)
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
e.recipe(t, "Bean Bake", "Bean Bake", 31)
e.reply(
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
idea("Bean Enchiladas", "oven", 25, "black beans"),
idea("Rice Bowls", "stovetop", 15, "rice"),
idea("Bean Bake", "oven", 30, "black beans"),
)
res, _, err := e.run(t, pantry, quickOnly)
if err != nil {
t.Fatal(err)
}
quick := map[string]bool{}
for _, i := range res.Ideas {
quick[i.Name] = i.Quick
}
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
wantDropped := 2
if !quickOnly {
want["Bean Enchiladas"], want["Bean Bake"] = false, false
wantDropped = 0
}
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
}
for _, i := range res.Ideas {
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
}
}
})
}
}
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
e := newEnv(t)
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
var reply []string
for _, name := range slow {
e.recipe(t, name, name, 90)
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
}
e.recipe(t, "Fried Rice", "Fried Rice", 20)
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
e.reply(reply...)
res, _, err := e.run(t, pantry, true)
if err != nil {
t.Fatal(err)
}
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
}
}
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
)
res, _, err := e.run(t, pantry, false)
if err != nil {
t.Fatal(err)
}
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
}
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
}
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
t.Errorf("searched %q, want only the idea kept", got)
}
}
func TestFailures(t *testing.T) {
for _, tc := range []struct {
name string
setup func(e *env)
code string
}{
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
{"search fails", func(e *env) {
e.search.Err = errors.New("boom")
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
}, ReasonSearchFailed},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
tc.setup(e)
_, published, err := e.run(t, pantry, false)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("err = %v, want failure %s", err, tc.code)
}
for _, p := range published {
if len(p.Ideas) != 0 {
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
}
}
})
}
}
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
e := newEnv(t)
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)
}
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))
}
}
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Fried Rice", "Fried Rice", 20)
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
t.Fatal("a repeated ingredient was accepted")
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
t.Fatalf("err = %v, want an *InvalidError", err)
}
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
var job jobs.Job
for {
job, err = e.runner.Get(t.Context(), started.JobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("job still %s", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
t.Fatalf("job = %+v", job)
}
var res Result
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
t.Fatalf("result = %+v", res)
}
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
t.Errorf("idea = %+v", got)
}
}
func TestIsQuick(t *testing.T) {
m := func(n int) *int { return &n }
for _, tc := range []struct {
method string
minutes *int
want bool
}{
{"crockpot", m(480), true},
{"crockpot", nil, true},
{"stovetop", m(30), true},
{"oven", m(31), false},
{"stovetop", nil, false},
} {
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
}
}
}