Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
270 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|