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>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
// Package ideas finds dinner ideas for Cook from what I have: from a list
|
||||
// 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.
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
)
|
||||
|
||||
// KindFromIngredients is the job that finds ideas from ingredients.
|
||||
const KindFromIngredients = "ideas_from_ingredients"
|
||||
|
||||
// QuickMinutes is the most minutes from prep to table of a Quick dinner
|
||||
// not cooked in a crock pot.
|
||||
const QuickMinutes = 30
|
||||
|
||||
// Job stages, as the job's result reports them.
|
||||
const (
|
||||
StageSuggesting = "suggesting"
|
||||
StageGrounding = "grounding"
|
||||
StageDone = "done"
|
||||
)
|
||||
|
||||
// Reasons the job failed, stored as its failure code. Any other failure,
|
||||
// such as the database's, is the runner's internal_error.
|
||||
const (
|
||||
// ReasonAIFailed: ideas_from_ingredients, or normalize_recipe while
|
||||
// grounding, failed; no idea is made up instead (D-017).
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
)
|
||||
|
||||
// Reasons are every failure reason of the job.
|
||||
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed}
|
||||
|
||||
// InvalidError is a request the household must correct, such as an empty
|
||||
// ingredient list.
|
||||
type InvalidError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *InvalidError) Error() string { return e.Message }
|
||||
|
||||
// Generator runs ideas_from_ingredients on in; scope is
|
||||
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
|
||||
// with the household's harness.Config.
|
||||
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
|
||||
|
||||
// Service starts and runs idea jobs.
|
||||
type Service struct {
|
||||
grounder *recipes.Grounder
|
||||
generate Generator
|
||||
logger *slog.Logger
|
||||
job *jobs.Kind[FromIngredientsJob]
|
||||
}
|
||||
|
||||
// NewService returns a service that finds ideas with generate, grounds
|
||||
// them with grounder and registers the KindFromIngredients job on runner,
|
||||
// which must not have started yet. The job is started only by
|
||||
// StartFromIngredients, which checks the ingredient list first.
|
||||
func NewService(grounder *recipes.Grounder, generate Generator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case grounder == nil:
|
||||
return nil, errors.New("ideas: nil grounder")
|
||||
case generate == nil:
|
||||
return nil, errors.New("ideas: nil generator")
|
||||
case runner == nil:
|
||||
return nil, errors.New("ideas: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("ideas: nil logger")
|
||||
}
|
||||
s := &Service{grounder: grounder, generate: generate, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindFromIngredients, s.run, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.job = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
|
||||
// on hand, as skills.CleanIngredients returns them, and whether only
|
||||
// Quick dinners are wanted.
|
||||
type FromIngredientsJob struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// Started is the job a request started.
|
||||
type Started struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
|
||||
// and returns its id. An ingredient list skills.CleanIngredients refuses
|
||||
// is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
|
||||
}
|
||||
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// Idea is a grounded idea as the job's result shows it. Method,
|
||||
// MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have.
|
||||
Missing []string `json:"missing"`
|
||||
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
|
||||
// missing: 1 lime" (see skills.IdeaSummary).
|
||||
Summary string `json:"summary"`
|
||||
// Method is stovetop, oven, crockpot, grill or other.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Quick says whether the idea is a Quick dinner (see IsQuick).
|
||||
Quick bool `json:"quick"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// Result is the KindFromIngredients result, published as the job goes:
|
||||
// its stage, the request, the ideas grounded so far in the model's order,
|
||||
// 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.
|
||||
type Result struct {
|
||||
Stage string `json:"stage"`
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
Ideas []Idea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// IsQuick reports whether a recipe cooked by method in minutes (nil when
|
||||
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
|
||||
// from prep to table.
|
||||
func IsQuick(method string, minutes *int) bool {
|
||||
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
|
||||
}
|
||||
|
||||
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
|
||||
// skills.IdeasRequested ideas, then grounds them in the model's order, at
|
||||
// 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.
|
||||
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 {
|
||||
var failure *jobs.Failure
|
||||
if ctx.Err() == nil && 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)
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// fill does run's work, returning a *jobs.Failure for a failure the
|
||||
// household may see.
|
||||
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
|
||||
}
|
||||
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 Result{}, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return Result{}, 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 {
|
||||
res.Dropped++
|
||||
continue
|
||||
}
|
||||
idea := ideaOf(round[i], *r)
|
||||
if p.QuickOnly && !idea.Quick {
|
||||
res.Dropped++
|
||||
continue
|
||||
}
|
||||
res.Ideas = append(res.Ideas, idea)
|
||||
if err := publish(res); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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 ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
out[i] = &r.Grounded
|
||||
case errors.As(r.Err, &ungroundable):
|
||||
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
|
||||
return nil, &jobs.Failure{Code: ReasonSearchNotConfigured}
|
||||
case errors.Is(r.Err, recipes.ErrSearchFailed):
|
||||
return nil, &jobs.Failure{Code: ReasonSearchFailed}
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
return nil, &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
return nil, fmt.Errorf("ideas: ground %q: %w", ideas[i].Name, r.Err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
|
||||
return Idea{
|
||||
Name: idea.Name,
|
||||
Uses: idea.Uses,
|
||||
Missing: idea.Missing,
|
||||
Summary: idea.Summary,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
Quick: IsQuick(gr.Method, gr.MinutesTotal),
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
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/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; normalize answers
|
||||
// normalize_recipe while grounding.
|
||||
model *llm.Fake
|
||||
normalize *llm.Fake
|
||||
// fail, when set, fails ideas_from_ingredients without a model call.
|
||||
fail error
|
||||
}
|
||||
|
||||
// newEnv returns an ideas service over a migrated store and an unstarted
|
||||
// job runner. It finds ideas through the real harness and
|
||||
// ideas_from_ingredients 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)
|
||||
}
|
||||
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{},
|
||||
}
|
||||
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)
|
||||
}
|
||||
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(g, generate, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestEveryIdeaUngroundableEndsDoneEmpty(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"))
|
||||
res, _, err := e.run(t, pantry, false)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user