- A run that keeps no idea fails with no_ideas_found instead of ending
done with an empty list.
- Before an ideas job fails it republishes its result with no ideas, so
GET /api/v1/jobs/{id} never shows ideas on a failed job, even ones a
previous grounding round already published.
- Each idea is published as soon as its grounding finishes, placed in the
model's order. recipes.GroundEach reports items as they finish;
GroundMany is rebuilt on it with unchanged semantics.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
9.5 KiB
Go
233 lines
9.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"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/ideas"
|
|
"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"
|
|
)
|
|
|
|
const ideasPath = "/api/v1/ideas/from-ingredients"
|
|
|
|
// newIdeasRouter returns a router whose ideas service runs
|
|
// ideas_from_ingredients through the real harness with the scripted model
|
|
// and grounds with fake search, pages and images and the scripted
|
|
// normalize. Its job runner is started when start is set.
|
|
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
runner, err := jobs.NewRunner(st, logger, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, 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 model, nil },
|
|
Store: st,
|
|
Guard: guard,
|
|
}
|
|
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
|
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
|
}
|
|
svc, err := ideas.NewService(g, generate, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
|
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if start {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if err := runner.Start(ctx); err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
runner.Wait()
|
|
})
|
|
}
|
|
return h, runner
|
|
}
|
|
|
|
func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) {
|
|
t.Parallel()
|
|
h, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
|
|
|
for _, tc := range []struct{ body, message string }{
|
|
{`{"quick_only": false}`, "ingredients: required"},
|
|
{`{"ingredients": ["rice"]}`, "quick_only: required"},
|
|
{`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"},
|
|
{`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"},
|
|
{`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`},
|
|
{`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"},
|
|
{`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
|
} {
|
|
rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
|
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
|
continue
|
|
}
|
|
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
|
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize := &llm.Fake{}, &llm.Fake{}
|
|
search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}}
|
|
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
|
"https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25),
|
|
}}
|
|
images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}}
|
|
js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}`
|
|
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
h, runner := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
|
|
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
id, _ := body["jobId"].(string)
|
|
if id == "" || len(body) != 1 {
|
|
t.Fatalf("POST answered %v, want only a jobId", body)
|
|
}
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
job, err := runner.Get(t.Context(), id)
|
|
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)
|
|
}
|
|
rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
|
if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" {
|
|
t.Fatalf("GET job = %d %v", rec.Code, job)
|
|
}
|
|
res := job["result"].(map[string]any)
|
|
if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 {
|
|
t.Fatalf("result = %v", res)
|
|
}
|
|
list := res["ideas"].([]any)
|
|
if len(list) != 1 {
|
|
t.Fatalf("ideas = %v, want 1", list)
|
|
}
|
|
got := list[0].(map[string]any)
|
|
for key, want := range map[string]any{
|
|
"name": "Chicken Fried Rice",
|
|
"summary": "Uses 2 of 3 · missing: 2 eggs",
|
|
"method": "stovetop",
|
|
"minutes_total": 25.0,
|
|
"quick": true,
|
|
"site_name": "R Kitchen",
|
|
"source_url": "https://r.example/fried-rice",
|
|
} {
|
|
if got[key] != want {
|
|
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
|
}
|
|
}
|
|
if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` {
|
|
t.Errorf("idea uses = %s, want the household's two listed ingredients", uses)
|
|
}
|
|
if got["recipe_id"] == "" || got["photo_url"] == "" {
|
|
t.Errorf("idea = %v, want a recipe and photo", got)
|
|
}
|
|
}
|
|
|
|
func TestFailedIdeasJobShowsNoIdeas(t *testing.T) {
|
|
t.Parallel()
|
|
idea := func(name string) string {
|
|
return fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name)
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
// grounds are the ideas a recipe grounds; the others ground none.
|
|
grounds []string
|
|
ideas []string
|
|
fail map[string]error
|
|
code string
|
|
}{
|
|
{"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"},
|
|
{"a later round fails after ideas were shown",
|
|
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
|
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
|
map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
model, normalize := &llm.Fake{}, &llm.Fake{}
|
|
search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: tc.fail}
|
|
pages := &recipestest.Pages{Bodies: map[string][]byte{}}
|
|
images := &recipestest.Images{JPEGs: map[string][]byte{}}
|
|
for i, name := range tc.grounds {
|
|
page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i)
|
|
search.Results[name] = []recipes.Candidate{{URL: page}}
|
|
pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25)
|
|
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
|
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
}
|
|
list := make([]string, len(tc.ideas))
|
|
for i, name := range tc.ideas {
|
|
list[i] = idea(name)
|
|
}
|
|
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
|
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
h, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
|
|
|
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`)
|
|
if rec.Code != http.StatusAccepted {
|
|
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
job := waitJob(t, h, body["jobId"].(string))
|
|
if job["status"] != "failed" || job["error"] != tc.code {
|
|
t.Fatalf("GET job = %v, want failed with %s", job, tc.code)
|
|
}
|
|
if res, _ := job["result"].(map[string]any); res == nil || len(res["ideas"].([]any)) != 0 {
|
|
t.Fatalf("failed job result = %v, want no ideas", job["result"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJobsEndpointRefusesIdeasKind(t *testing.T) {
|
|
t.Parallel()
|
|
h, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
|
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`)
|
|
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
|
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
|
}
|
|
}
|