184 lines
5.5 KiB
Go
184 lines
5.5 KiB
Go
// Package recipestest provides fakes for tests that ground dinners outside
|
||||
|
|
// package recipes: a search, page and image fetchers serving fixtures, and
|
|||
|
|
// a Grounder over them that normalizes through the real harness and
|
|||
|
|
// normalize_recipe skill with a scripted model.
|
|||
|
|
package recipestest
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"image"
|
|||
|
|
"image/color"
|
|||
|
|
"image/jpeg"
|
|||
|
|
"sync"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"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/recipes"
|
|||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
|||
|
|
// each query from Results. Err, when set, fails every search. Gate, when
|
|||
|
|
// set, makes every search wait for it to close.
|
|||
|
|
type Search struct {
|
|||
|
|
Results map[string][]recipes.Candidate
|
|||
|
|
Err error
|
|||
|
|
Gate chan struct{}
|
|||
|
|
|
|||
|
|
mu sync.Mutex
|
|||
|
|
queries []string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
|
|||
|
|
|
|||
|
|
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
|
|||
|
|
s.mu.Lock()
|
|||
|
|
s.queries = append(s.queries, query)
|
|||
|
|
s.mu.Unlock()
|
|||
|
|
if s.Gate != nil {
|
|||
|
|
select {
|
|||
|
|
case <-s.Gate:
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return nil, ctx.Err()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if s.Err != nil {
|
|||
|
|
return nil, s.Err
|
|||
|
|
}
|
|||
|
|
c := s.Results[query]
|
|||
|
|
return c[:min(n, len(c))], nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Queries returns every query searched, in order.
|
|||
|
|
func (s *Search) Queries() []string {
|
|||
|
|
s.mu.Lock()
|
|||
|
|
defer s.mu.Unlock()
|
|||
|
|
return append([]string(nil), s.queries...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
|
|||
|
|
// 404. Gate, when set, makes every fetch wait for it to close.
|
|||
|
|
type Pages struct {
|
|||
|
|
Bodies map[string][]byte
|
|||
|
|
Gate chan struct{}
|
|||
|
|
|
|||
|
|
mu sync.Mutex
|
|||
|
|
fetched []string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
|
|||
|
|
p.mu.Lock()
|
|||
|
|
p.fetched = append(p.fetched, rawURL)
|
|||
|
|
p.mu.Unlock()
|
|||
|
|
if p.Gate != nil {
|
|||
|
|
select {
|
|||
|
|
case <-p.Gate:
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return recipes.Page{}, ctx.Err()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
body, ok := p.Bodies[rawURL]
|
|||
|
|
if !ok {
|
|||
|
|
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
|||
|
|
}
|
|||
|
|
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fetched returns every URL fetched, in order.
|
|||
|
|
func (p *Pages) Fetched() []string {
|
|||
|
|
p.mu.Lock()
|
|||
|
|
defer p.mu.Unlock()
|
|||
|
|
return append([]string(nil), p.fetched...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
|
|||
|
|
// a 404.
|
|||
|
|
type Images struct {
|
|||
|
|
JPEGs map[string][]byte
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
|
|||
|
|
body, ok := i.JPEGs[rawURL]
|
|||
|
|
if !ok {
|
|||
|
|
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
|
|||
|
|
}
|
|||
|
|
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
|
|||
|
|
// different photos.
|
|||
|
|
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
|
|||
|
|
t.Helper()
|
|||
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|||
|
|
for y := range h {
|
|||
|
|
for x := range w {
|
|||
|
|
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
var buf bytes.Buffer
|
|||
|
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
return buf.Bytes()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// RecipePage is a page whose schema.org Recipe is title, from site, with
|
|||
|
|
// two ingredients, two steps, a total time of minutes and the image at
|
|||
|
|
// imageURL.
|
|||
|
|
func RecipePage(title, site, imageURL string, minutes int) []byte {
|
|||
|
|
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
|
|||
|
|
<meta property="og:site_name" content=%q>
|
|||
|
|
<script type="application/ld+json">{
|
|||
|
|
"@context": "https://schema.org",
|
|||
|
|
"@type": "Recipe",
|
|||
|
|
"name": %q,
|
|||
|
|
"image": %q,
|
|||
|
|
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
|
|||
|
|
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
|
|||
|
|
"totalTime": "PT%dM"
|
|||
|
|
}</script></head><body></body></html>`, site, title, imageURL, minutes)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
|
|||
|
|
// method and minutes.
|
|||
|
|
func NormalizedReply(method string, minutes int) llm.GenerateResult {
|
|||
|
|
js := fmt.Sprintf(`{"ingredients":[
|
|||
|
|
{"name":"onion","qty":"1","unit":"","note":""},
|
|||
|
|
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
|
|||
|
|
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
|
|||
|
|
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewGrounder returns a Grounder storing in st, with photos cached under
|
|||
|
|
// dataDir, normalizing through the real harness with model.
|
|||
|
|
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
|
|||
|
|
t.Helper()
|
|||
|
|
photos, err := recipes.NewPhotoCache(images, st, dataDir)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatal(err)
|
|||
|
|
}
|
|||
|
|
return g
|
|||
|
|
}
|