Files
MealQuest/internal/recipes/ground_test.go
T
dev_agentandClaude Opus 5.5 24d91b5bf6
CI / ci (push) Successful in 1m48s
CI / ci (pull_request) Successful in 1m48s
Rank stored candidates on their stored recipe without refetching
Ground no longer fetches the page of a candidate whose source URL is
already stored: the stored recipe, its photo, ingredients and steps are
scored as a fetched page would be, so ranking still decides between it
and new candidates, and a stored page that is now unavailable can still
be reused. Rank's fetch-and-extract is split out behind a loader so
Rank and Ground share one scoring path.

The reuse test removes the stored page and checks that only the new
candidate is fetched; the ranking regression test lists the unrelated
stored recipe first so search order cannot pass it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:35:37 -04:00

519 lines
18 KiB
Go

package recipes
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// fakeSearch is a SearchSource and SearchProvider answering each query
// from results, and failing a query in errs. With gate set, every Search
// reports its query on started and waits for gate to close.
type fakeSearch struct {
results map[string][]Candidate
errs map[string]error
started chan string
gate chan struct{}
mu sync.Mutex
calls int
active int
maxActive int
}
func (s *fakeSearch) Provider(context.Context) (SearchProvider, error) { return s, nil }
func (s *fakeSearch) Search(_ context.Context, query string, n int) ([]Candidate, error) {
s.mu.Lock()
s.calls++
s.active++
s.maxActive = max(s.maxActive, s.active)
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.active--
s.mu.Unlock()
}()
if s.gate != nil {
s.started <- query
<-s.gate
}
if err := s.errs[query]; err != nil {
return nil, err
}
c := s.results[query]
return c[:min(n, len(c))], nil
}
func (s *fakeSearch) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (f *fakeFetcher) fetchCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.fetched)
}
// groundPage is a recipe page for name with two ingredients, two steps, a
// 40-minute total and the image at https://example.com{imagePath}.
func groundPage(name, imagePath string) []byte {
return page(`<meta property="og:site_name" content="Example Kitchen">`, fmt.Sprintf(`{
"@context": "https://schema.org",
"@type": "Recipe",
"name": %q,
"image": "https://example.com%s",
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
"totalTime": "PT40M",
"recipeYield": "4"
}`, name, imagePath))
}
// normalizedReply is a usable normalize_recipe reply for groundPage.
func normalizedReply() llm.GenerateResult {
js := `{"ingredients":[
{"name":"onion","qty":"1","unit":"","note":""},
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
],"method":"stovetop","difficulty":null,"minutes_total":40}`
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// badReply is a normalize_recipe reply that fails validation.
func badReply() llm.GenerateResult {
js := `{"ingredients":[],"method":"stovetop","difficulty":null,"minutes_total":40}`
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
type groundEnv struct {
g *Grounder
st *store.Store
dataDir string
images *photoServer
pages *fakeFetcher
search *fakeSearch
llm *llm.Fake
}
// newGroundEnv returns a Grounder over pages, images served at
// https://example.com, search and a fake model, normalizing through the
// real harness and normalize_recipe skill.
func newGroundEnv(t *testing.T, pages map[string][]byte, images map[string]photoFile, search *fakeSearch) *groundEnv {
t.Helper()
photos, st, dataDir, imgSrv := newTestPhotoCache(t, images)
model := &llm.Fake{}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
Store: st,
Guard: guard,
}
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
return skills.NormalizeRecipe(ctx, cfg, scope, in)
}
fetcher := &fakeFetcher{pages: pages}
g, err := NewGrounder(st, search, fetcher, photos, normalize)
if err != nil {
t.Fatal(err)
}
return &groundEnv{g: g, st: st, dataDir: dataDir, images: imgSrv, pages: fetcher, search: search, llm: model}
}
func jpegFile(t *testing.T, w, h int) photoFile {
return photoFile{"image/jpeg", encodeJPEG(t, gradient(w, h))}
}
func countRows(t *testing.T, st *store.Store, table string) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(context.Background(), "SELECT count(*) FROM "+table).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestGroundHappyPath(t *testing.T) {
t.Parallel()
search := &fakeSearch{results: map[string][]Candidate{
"chicken curry": {
{URL: "https://a.example/missing"},
{URL: "https://b.example/curry"},
},
}}
env := newGroundEnv(t,
map[string][]byte{"https://b.example/curry": groundPage("Chicken Curry", "/img/curry.jpg")},
map[string]photoFile{"/img/curry.jpg": jpegFile(t, 800, 600)},
search)
env.llm.QueueGenerate(normalizedReply())
gr, err := env.g.Ground(t.Context(), "Chicken Curry", "chicken curry")
if err != nil {
t.Fatalf("Ground: %v", err)
}
if gr.RecipeID == "" || gr.PhotoID == "" {
t.Fatalf("Grounded = %+v, want recipe and photo ids", gr)
}
want := Grounded{
RecipeID: gr.RecipeID, PhotoID: gr.PhotoID, Method: "stovetop", MinutesTotal: gr.MinutesTotal,
Difficulty: "easy", SourceURL: "https://b.example/curry", SiteName: "Example Kitchen",
}
if gr != want || gr.MinutesTotal == nil || *gr.MinutesTotal != 40 {
t.Errorf("Grounded = %+v (minutes %v), want %+v with 40 minutes", gr, gr.MinutesTotal, want)
}
q := db.New(env.st.Reader)
rec, err := q.GetRecipe(t.Context(), gr.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.Title != "Chicken Curry" || rec.PhotoID.String != gr.PhotoID || rec.Method.String != "stovetop" ||
rec.Difficulty.String != "easy" || rec.MinutesTotal.Int64 != 40 || rec.TotalMinutes.Int64 != 40 || rec.Yield.String != "4" {
t.Errorf("recipe row = %+v", rec)
}
ings, err := q.ListRecipeIngredients(t.Context(), gr.RecipeID)
if err != nil {
t.Fatal(err)
}
if len(ings) != 2 || ings[1].RawText != "2 cloves garlic, minced" || ings[1].Name.String != "garlic" ||
ings[1].Qty.String != "2" || ings[1].Unit.String != "cloves" || ings[1].Note.String != "minced" || ings[0].Unit.Valid {
t.Errorf("ingredients = %+v", ings)
}
steps, err := q.ListRecipeSteps(t.Context(), gr.RecipeID)
if err != nil {
t.Fatal(err)
}
if len(steps) != 2 || steps[0].Text != "Chop the onion." || steps[1].Position != 1 {
t.Errorf("steps = %+v", steps)
}
photo, err := q.GetPhoto(t.Context(), gr.PhotoID)
if err != nil {
t.Fatal(err)
}
if photo.SourceUrl != "https://example.com/img/curry.jpg" {
t.Errorf("photo row = %+v", photo)
}
if _, err := os.Stat(filepath.Join(env.dataDir, filepath.FromSlash(photo.LocalPath))); err != nil {
t.Errorf("photo file: %v", err)
}
}
func TestGroundUngroundable(t *testing.T) {
t.Parallel()
search := &fakeSearch{results: map[string][]Candidate{
"mystery stew": {
{URL: "https://a.example/missing"},
{URL: "https://b.example/no-recipe"},
{URL: "https://c.example/tiny-photo"},
},
}}
env := newGroundEnv(t,
map[string][]byte{
"https://b.example/no-recipe": page(""),
"https://c.example/tiny-photo": groundPage("Mystery Stew", "/img/tiny.jpg"),
},
map[string]photoFile{"/img/tiny.jpg": jpegFile(t, 200, 200)},
search)
_, err := env.g.Ground(t.Context(), "Mystery Stew", "mystery stew")
var ungroundable *ErrUngroundable
if !errors.As(err, &ungroundable) {
t.Fatalf("Ground error = %v, want *ErrUngroundable", err)
}
if ungroundable.Target != "Mystery Stew" {
t.Errorf("Target = %q", ungroundable.Target)
}
for _, reason := range []error{ErrCandidatesUnranked, ErrNoRecipe, ErrPhotoTooSmall} {
if !errors.Is(err, reason) {
t.Errorf("error %v does not report %v", err, reason)
}
}
if n := len(env.llm.Requests()); n != 0 {
t.Errorf("%d model calls, want none before a photo is cached", n)
}
for _, table := range []string{"recipes", "recipe_ingredients", "photos"} {
if n := countRows(t, env.st, table); n != 0 {
t.Errorf("%d %s rows for an ungroundable dish", n, table)
}
}
if names := photoFiles(t, env.dataDir); len(names) != 0 {
t.Errorf("photo files stored for an ungroundable dish: %v", names)
}
_, err = env.g.Ground(t.Context(), "Nothing At All", "nothing at all")
if !errors.As(err, &ungroundable) || !errors.Is(err, ErrNoCandidates) {
t.Errorf("Ground with no search results error = %v, want *ErrUngroundable with ErrNoCandidates", err)
}
}
func TestGroundTriesNextCandidateAfterBadOutput(t *testing.T) {
t.Parallel()
search := &fakeSearch{results: map[string][]Candidate{
"curry": {{URL: "https://a.example/curry"}, {URL: "https://b.example/curry"}},
}}
env := newGroundEnv(t,
map[string][]byte{
"https://a.example/curry": groundPage("Curry", "/img/a.jpg"),
"https://b.example/curry": groundPage("Curry", "/img/b.jpg"),
},
map[string]photoFile{"/img/a.jpg": jpegFile(t, 640, 480), "/img/b.jpg": jpegFile(t, 480, 640)},
search)
// The first candidate's reply and repair are unusable; the second's is
// fine.
env.llm.QueueGenerate(badReply(), badReply(), normalizedReply())
gr, err := env.g.Ground(t.Context(), "Curry", "curry")
if err != nil {
t.Fatalf("Ground: %v", err)
}
if gr.SourceURL != "https://b.example/curry" {
t.Errorf("grounded in %s, want the second candidate", gr.SourceURL)
}
if n := countRows(t, env.st, "photos"); n != 1 {
t.Errorf("%d photos rows, want only the grounded recipe's", n)
}
}
func TestGroundSearchFailureIsNotUngroundable(t *testing.T) {
t.Parallel()
env := newGroundEnv(t, nil, nil, &fakeSearch{errs: map[string]error{"curry": ErrSearchRateLimited}})
_, err := env.g.Ground(t.Context(), "Curry", "curry")
var ungroundable *ErrUngroundable
if !errors.Is(err, ErrSearchRateLimited) || errors.As(err, &ungroundable) {
t.Errorf("Ground error = %v, want ErrSearchRateLimited and not *ErrUngroundable", err)
}
}
func TestGroundReusesStoredRecipe(t *testing.T) {
t.Parallel()
search := &fakeSearch{results: map[string][]Candidate{
"curry": {{URL: "https://b.example/curry"}},
"chicken curry": {{URL: "https://a.example/other"}, {URL: "https://b.example/curry"}},
}}
env := newGroundEnv(t,
map[string][]byte{"https://b.example/curry": groundPage("Curry", "/img/curry.jpg")},
map[string]photoFile{"/img/curry.jpg": jpegFile(t, 640, 480)},
search)
env.llm.QueueGenerate(normalizedReply())
first, err := env.g.Ground(t.Context(), "Curry", "curry")
if err != nil {
t.Fatalf("first Ground: %v", err)
}
pages, images, calls := env.pages.fetchCount(), env.images.requests.Load(), len(env.llm.Requests())
// The stored page is gone and the model has no reply left: only the
// stored recipe can ground the dish.
delete(env.pages.pages, "https://b.example/curry")
again, err := env.g.Ground(t.Context(), "Chicken Curry", "chicken curry")
if err != nil {
t.Fatalf("second Ground: %v", err)
}
if again.RecipeID != first.RecipeID || again.PhotoID != first.PhotoID || again.Method != first.Method ||
again.Difficulty != first.Difficulty || *again.MinutesTotal != *first.MinutesTotal || again.SiteName != first.SiteName {
t.Errorf("reused grounding = %+v, want %+v", again, first)
}
if env.images.requests.Load() != images || len(env.llm.Requests()) != calls {
t.Errorf("reuse fetched images %d→%d, model calls %d→%d; want none",
images, env.images.requests.Load(), calls, len(env.llm.Requests()))
}
// Ranking fetches only the candidate not stored; the stored one ranks
// on its stored recipe.
env.pages.mu.Lock()
fetched := append([]string(nil), env.pages.fetched[pages:]...)
env.pages.mu.Unlock()
if len(fetched) != 1 || fetched[0] != "https://a.example/other" {
t.Errorf("reuse fetched %v, want only https://a.example/other", fetched)
}
pages = env.pages.fetchCount()
byURL, err := env.g.GroundURL(t.Context(), "https://b.example/curry")
if err != nil || byURL.RecipeID != first.RecipeID {
t.Errorf("GroundURL of the stored page = %+v, %v; want the stored recipe", byURL, err)
}
if env.pages.fetchCount() != pages {
t.Error("GroundURL refetched a stored page")
}
if n := countRows(t, env.st, "recipes"); n != 1 {
t.Errorf("%d recipes rows, want 1", n)
}
}
func TestGroundStoredRecipeDoesNotOutrankRelevantOne(t *testing.T) {
t.Parallel()
search := &fakeSearch{results: map[string][]Candidate{
"brownies": {{URL: "https://b.example/brownies"}},
"chicken curry": {{URL: "https://b.example/brownies"}, {URL: "https://a.example/curry"}},
}}
env := newGroundEnv(t,
map[string][]byte{
"https://a.example/curry": groundPage("Chicken Curry", "/img/curry.jpg"),
"https://b.example/brownies": groundPage("Fudgy Brownies", "/img/brownies.jpg"),
},
map[string]photoFile{
"/img/curry.jpg": jpegFile(t, 640, 480),
"/img/brownies.jpg": jpegFile(t, 640, 480),
},
search)
env.llm.QueueGenerate(normalizedReply(), normalizedReply())
brownies, err := env.g.Ground(t.Context(), "Brownies", "brownies")
if err != nil {
t.Fatalf("Ground brownies: %v", err)
}
// Search lists the unrelated stored recipe first and the relevant new
// one second: ranking, not search order or storage, grounds the
// relevant one.
curry, err := env.g.Ground(t.Context(), "Chicken Curry", "chicken curry")
if err != nil {
t.Fatalf("Ground chicken curry: %v", err)
}
if curry.SourceURL != "https://a.example/curry" || curry.RecipeID == brownies.RecipeID {
t.Errorf("grounded Chicken Curry in %s (recipe %s), want https://a.example/curry, not the stored brownies %s",
curry.SourceURL, curry.RecipeID, brownies.RecipeID)
}
if n := countRows(t, env.st, "recipes"); n != 2 {
t.Errorf("%d recipes rows, want 2", n)
}
}
func TestGroundURL(t *testing.T) {
t.Parallel()
env := newGroundEnv(t,
map[string][]byte{
"https://b.example/pasted": groundPage("Grandma's Slow Cooker Chili", "/img/chili.jpg"),
"https://b.example/no-image": recipePage("Chili", true, false, true, true),
"https://b.example/no-time": page("", `{"@type":"Recipe","name":"Chili","image":"https://example.com/img/chili.jpg",
"recipeIngredient":["1 onion"],"totalTime":"PT0M"}`),
},
map[string]photoFile{"/img/chili.jpg": jpegFile(t, 640, 480)},
&fakeSearch{})
env.llm.QueueGenerate(normalizedReply())
gr, err := env.g.GroundURL(t.Context(), "https://b.example/pasted")
if err != nil {
t.Fatalf("GroundURL: %v", err)
}
if gr.SourceURL != "https://b.example/pasted" || gr.Method != "crockpot" || gr.PhotoID == "" {
t.Errorf("Grounded = %+v, want the pasted URL, crockpot from the title", gr)
}
if env.search.callCount() != 0 {
t.Error("GroundURL searched")
}
for rawURL, reason := range map[string]error{
"https://b.example/no-image": ErrNoImage,
"https://b.example/no-time": ErrTotalTimeNotPositive,
"https://b.example/missing": nil,
} {
_, err := env.g.GroundURL(t.Context(), rawURL)
var ungroundable *ErrUngroundable
if !errors.As(err, &ungroundable) || ungroundable.Target != rawURL {
t.Errorf("GroundURL(%s) error = %v, want *ErrUngroundable", rawURL, err)
}
var status *StatusError
if reason != nil && !errors.Is(err, reason) || reason == nil && !errors.As(err, &status) {
t.Errorf("GroundURL(%s) error = %v, want reason %v", rawURL, err, reason)
}
}
}
func TestGroundManyIsolatesFailures(t *testing.T) {
t.Parallel()
search := &fakeSearch{
results: map[string][]Candidate{
"curry": {{URL: "https://a.example/curry"}},
"nothing": {{URL: "https://a.example/missing"}},
"chili": {{URL: "https://b.example/chili"}},
},
errs: map[string]error{"broken": ErrSearchRefused},
}
env := newGroundEnv(t,
map[string][]byte{
"https://a.example/curry": groundPage("Curry", "/img/curry.jpg"),
"https://b.example/chili": groundPage("Chili", "/img/chili.jpg"),
},
map[string]photoFile{"/img/curry.jpg": jpegFile(t, 640, 480), "/img/chili.jpg": jpegFile(t, 480, 640)},
search)
env.llm.QueueGenerate(normalizedReply(), normalizedReply())
results := env.g.GroundMany(t.Context(), []GroundRequest{
{DishName: "Curry", Query: "curry"},
{DishName: "Nothing", Query: "nothing"},
{DishName: "Broken", Query: "broken"},
{DishName: "Chili", Query: "chili"},
})
if len(results) != 4 {
t.Fatalf("%d results, want 4", len(results))
}
if results[0].Err != nil || results[0].Grounded.SourceURL != "https://a.example/curry" {
t.Errorf("curry = %+v", results[0])
}
var ungroundable *ErrUngroundable
if !errors.As(results[1].Err, &ungroundable) {
t.Errorf("nothing error = %v, want *ErrUngroundable", results[1].Err)
}
if !errors.Is(results[2].Err, ErrSearchRefused) {
t.Errorf("broken error = %v, want ErrSearchRefused", results[2].Err)
}
if results[3].Err != nil || results[3].Grounded.SourceURL != "https://b.example/chili" {
t.Errorf("chili = %+v", results[3])
}
if n := countRows(t, env.st, "recipes"); n != 2 {
t.Errorf("%d recipes rows, want 2", n)
}
}
func TestGroundManyLimitsConcurrency(t *testing.T) {
t.Parallel()
search := &fakeSearch{started: make(chan string, 5), gate: make(chan struct{})}
env := newGroundEnv(t, nil, nil, search)
reqs := make([]GroundRequest, 5)
for i := range reqs {
reqs[i] = GroundRequest{DishName: fmt.Sprintf("Dish %d", i), Query: fmt.Sprintf("dish %d", i)}
}
done := make(chan []GroundResult)
go func() { done <- env.g.GroundMany(t.Context(), reqs) }()
for range GroundConcurrency {
select {
case <-search.started:
case <-time.After(5 * time.Second):
t.Fatal("fewer than GroundConcurrency items started")
}
}
select {
case q := <-search.started:
t.Fatalf("%s started while %d were in flight", q, GroundConcurrency)
case <-time.After(100 * time.Millisecond):
}
close(search.gate)
results := <-done
for i, r := range results {
if !errors.Is(r.Err, ErrNoCandidates) {
t.Errorf("item %d error = %v, want ErrNoCandidates", i, r.Err)
}
}
if search.maxActive != GroundConcurrency || search.callCount() != 5 {
t.Errorf("max in flight %d of %d calls, want %d of 5", search.maxActive, search.callCount(), GroundConcurrency)
}
}