Files
MealQuest/internal/ai/skills/ideas_from_ingredients_test.go
T
dev_agentandClaude Opus 5.5 b566be7ddc
CI / ci (push) Successful in 5m51s
CI / ci (pull_request) Successful in 5m45s
Add ideas_from_ingredients skill and job
Implement the ideas_from_ingredients v1 skill on the AI harness: it asks
for 8 ideas {name, uses[], missing[], est_minutes, method, difficulty,
search_query}, keeps only "uses" entries whose harness.Normalize form is
one of the household's listed ingredients (written as the household wrote
them, in its order), drops ideas using none or repeating another, and
computes the summary line from the corrected lists.

Add internal/ideas: an ideas_from_ingredients job that grounds the ideas
with Grounder.GroundMany in the model's order until 5 are kept, drops the
ungroundable ones and, for quick_only, those whose grounded recipe is not
a Quick dinner (crockpot or minutes_total <= 30), publishing the result
as each idea is kept. POST /api/v1/ideas/from-ingredients answers 202
{jobId}.

Maestro task c3c5f98e-7f7d-4c6b-a1ab-d45bb8e497f9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:01:03 -04:00

270 lines
10 KiB
Go

package skills
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// pantry is the household's ingredient list in its order.
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
// idea renders one idea of an ideas_from_ingredients reply.
func idea(name, method string, minutes int, uses, missing []string) string {
u, _ := json.Marshal(append([]string{}, uses...))
m, _ := json.Marshal(append([]string{}, missing...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
}
// ideasReply renders a reply holding the ideas given.
func ideasReply(ideas ...string) string {
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
}
// ideas runs IdeasFromIngredients on in with f and checks f was called
// exactly calls times, each at the household temperature.
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
t.Helper()
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), in)
reqs := f.Requests()
if len(reqs) != calls {
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
}
if g, _, _ := f.Remaining(); g != 0 {
t.Fatalf("%d scripted Generate results unused", g)
}
for i, req := range reqs {
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
}
if req.Schema == nil || req.Schema.Name != "ideas_from_ingredients" {
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestIdeasFromIngredientsPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
_, reqs, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "ideas_from_ingredients_any.system.golden", system)
golden(t, "ideas_from_ingredients_any.user.golden", user)
for _, part := range []string{
"## Household context",
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
"## Output contract",
`"uses"`,
`"missing"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Contains(system, "Quick dinners only") {
t.Error("an Any time request asks for Quick dinners only")
}
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
t.Errorf("user message = %q", user)
}
}
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
in := pantry
in.QuickOnly = true
_, reqs, err := ideas(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
}
}
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// Every ingredient, named in another order and spelling: uses is
// in the household's order, as the household wrote it.
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
// Some of them.
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
// Every ingredient, nothing missing.
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
// Some, nothing missing.
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Dropped) != 0 {
t.Fatalf("dropped %+v", out.Dropped)
}
got := map[string][2]any{}
for _, i := range out.Ideas {
got[i.Name] = [2]any{i.Uses, i.Summary}
}
want := map[string][2]any{
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
}
}
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// The model claims parmesan and lime, which the household never
// listed, and repeats rice.
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
// Uses only ingredients the household never listed.
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
// Lists an ingredient on hand as missing.
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
want := Ideas{
Ideas: []Idea{
{
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
Summary: "Uses 2 of 5 · missing: 1 cup broth",
},
{
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
Summary: "Uses 1 of 5 · missing: tortillas",
},
},
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("output = %+v\nwant %+v", out, want)
}
}
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
t.Fatalf("ideas = %+v", out.Ideas)
}
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
}
}
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
out, reqs, err := ideas(t, &f, pantry, 2)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 {
t.Fatalf("ideas = %+v", out.Ideas)
}
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
if !strings.Contains(repair, part) {
t.Errorf("repair turn lacks %q:\n%s", part, repair)
}
}
}
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
var f llm.Fake
var nine []string
for i := range 9 {
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
}
f.QueueGenerate(reply(ideasReply(nine...)))
f.QueueGenerate(reply(ideasReply(nine...)))
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
t.Fatalf("err = %v, want a bad output naming the limit", err)
}
}
func TestCleanIngredients(t *testing.T) {
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
if err != nil {
t.Fatal(err)
}
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CleanIngredients = %q, want %q", got, want)
}
tooMany := make([]string, MaxIngredients+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("thing %d", i)
}
for _, tc := range []struct {
name string
in []string
want string
}{
{"empty", nil, "list at least one ingredient"},
{"too many", tooMany, "list at most 20"},
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
{"filler only", []string{"the"}, "names no ingredient"},
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
var f llm.Fake
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
t.Fatal("a repeated ingredient was accepted")
}
}
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
t.Errorf("scopes of the same list differ: %q, %q", a, b)
}
for _, other := range []IdeasRequest{
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
{Ingredients: []string{"black beans", "Rice"}},
{Ingredients: []string{"Rice"}},
} {
if IdeasScope(other) == a {
t.Errorf("%+v shares the scope %q", other, a)
}
}
}