Add ideas_from_ingredients skill and job
CI / ci (push) Successful in 5m51s
CI / ci (pull_request) Successful in 5m45s

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:
2026-10-08 19:01:03 -04:00
co-authored by Claude Opus 5.5
parent cb451baed4
commit b566be7ddc
24 changed files with 1817 additions and 25 deletions
+21 -2
View File
@@ -23,6 +23,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -138,6 +139,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
ideasSvc, err := ideas.NewService(grounder, ideaFinder(ai, guard), runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -154,7 +160,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -280,7 +286,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner and
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
// ideas.NewService ideas.KindFromIngredients).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -337,6 +344,18 @@ func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler
}
}
// ideaFinder runs ideas_from_ingredients with the AI settings current at
// each run, sharing guard.
func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.Ideas{}, err
}
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+38
View File
@@ -308,6 +308,28 @@ automatic** (D-021).
still `pending` or `running` with no job queued or running (the process
stopped, or recording its end failed) is set `failed`, so it does not
block the next.
- Cook from what I have (`ideas.Service`, Feature 18). `POST
/api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both
required) checks the list with `skills.CleanIngredients` — 1 to 20
entries, each non-blank, at most 80 characters, with a non-empty
`harness.Normalize` form that no earlier entry shares — answering 400
`invalid_request` otherwise, then enqueues an `ideas_from_ingredients`
job (service-only: `POST /jobs` refuses the kind) and answers 202
`{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope:
`skills.IdeasScope`, the flag and the normalized list in order), then
grounds its ideas in the model's order with `Grounder.GroundMany`, as
many as are still wanted at a time, until 5 are kept or none are left.
An idea no recipe grounds is dropped; with `quick_only`, so is one whose
grounded recipe is not a Quick dinner (`ideas.IsQuick`: method
`crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's
`est_minutes`). Ideas are not stored as dinners. The job publishes
`{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary,
method, minutes_total, difficulty, quick, recipe_id, photo_url,
source_url, site_name}], dropped}` at each stage (`suggesting`,
`grounding`, `done`) and again each time an idea is kept, so the result
fills as the ideas ground; it may end `done` with no ideas. It fails
with `ai_failed` (the skill or `normalize_recipe` failed; no idea is
made up instead, D-017), `search_not_configured` or `search_failed`.
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
cited_regular_ids, status, dinner_status, decided_at, created_at}],
@@ -470,6 +492,22 @@ automatic** (D-021).
not a Regular; and one whose reason is over 180 characters or names none
of its cited Regulars. Cited names are rewritten as the household writes
them. Grounding each survivor in a recipe is the caller's next step.
- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill
`ideas_from_ingredients` v1): from the household's ingredient list (the
"Ingredients on hand" context section) and the Quick only flag, asks for
8 ideas `{name, uses[], missing[], est_minutes, method, difficulty,
search_query}` at the household temperature. Malformed replies (a
missing field, a bad enum, an empty name, more than 8 ideas) get the one
repair turn. The invariant is enforced in Go: a `uses` entry is kept
only when its `harness.Normalize` form is a listed ingredient's, and is
then written as the household wrote it, once, in the household's order;
`missing` loses blanks, repeats and anything listed. An idea left using
none of the listed ingredients, or repeating an earlier idea
(`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary
line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
missing: …" otherwise, with "nothing missing" in place of the list when
nothing is.
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
v1): from the names of the week's planned dinners in day order, writes
`{title}`, the Supply run's post title in the Supply archive, at the
@@ -0,0 +1,356 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
const (
// IdeasRequested is how many ideas ideas_from_ingredients asks the
// model for; validation and grounding drop some.
IdeasRequested = 8
// IdeasKept is the most ideas the household is shown.
IdeasKept = 5
// MaxIngredients is the most ingredients the household may list.
MaxIngredients = 20
// IngredientLimit is the most characters one listed ingredient may
// have.
IngredientLimit = 80
)
// IdeasRequest is what ideas_from_ingredients reads.
type IdeasRequest struct {
// Ingredients are the ingredients the household has on hand, in its
// order, as CleanIngredients returns them.
Ingredients []string
// QuickOnly asks the model for Quick dinners only. It is a hint: the
// caller enforces it after grounding, from the grounded recipe.
QuickOnly bool
}
// Idea is one dinner idea. Method, EstMinutes and Difficulty are the
// model's estimates until the idea is grounded in a recipe.
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. PostProcess drops every entry that is
// not one of them.
Uses []string `json:"uses"`
// Missing are the ingredients the idea needs that the household does
// not have, in the model's order.
Missing []string `json:"missing"`
EstMinutes int `json:"est_minutes"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
// Summary is the line the household reads under the idea, computed
// by IdeaSummary from Uses and Missing. It is never part of the
// model's reply.
Summary string `json:"-"`
}
// Ideas is ideas_from_ingredients' output.
type Ideas struct {
// Ideas are the ideas that passed every rule, in the model's order.
Ideas []Idea `json:"ideas"`
// Dropped are the ideas the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedIdea `json:"-"`
}
// DroppedIdea is an idea dropped by PostProcess.
type DroppedIdea struct {
Name string
// Why says which rule it broke.
Why string
}
// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns
// ideas whose uses hold only the household's own ingredients: an entry
// naming anything else is removed, and an idea left using none of them is
// dropped, as is one repeating an earlier idea. Each kept idea carries its
// summary line, computed from the corrected uses and missing lists. scope
// names the request (see harness.Run; IdeasScope). An ingredient list
// CleanIngredients refuses fails before any LLM call.
func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) {
ings, err := CleanIngredients(in.Ingredients)
if err != nil {
return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err)
}
in.Ingredients = ings
return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in)
}
// IdeasScope is the harness scope of a request: its normalized
// ingredients, in order, and whether it is for Quick dinners only, so two
// requests share a run only when the model would be asked the same.
func IdeasScope(in IdeasRequest) string {
parts := make([]string, len(in.Ingredients))
for i, ing := range in.Ingredients {
parts[i] = harness.Normalize(ing)
}
return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|")
}
// CleanIngredients returns the household's ingredient list with each entry
// on one line, or why it cannot be used: it is empty or longer than
// MaxIngredients, or an entry is blank, longer than IngredientLimit
// characters, has nothing left once normalized (harness.Normalize), or
// normalizes the same as an earlier one.
func CleanIngredients(list []string) ([]string, error) {
if len(list) == 0 {
return nil, errors.New("list at least one ingredient")
}
if len(list) > MaxIngredients {
return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients)
}
out := make([]string, len(list))
seen := map[string]string{}
for i, ing := range list {
ing = oneLine(ing)
switch {
case ing == "":
return nil, fmt.Errorf("ingredient %d is blank", i+1)
case utf8.RuneCountInString(ing) > IngredientLimit:
return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit)
}
n := harness.Normalize(ing)
if n == "" {
return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing)
}
if first, ok := seen[n]; ok {
return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first)
}
seen[n] = ing
out[i] = ing
}
return out, nil
}
// IdeaSummary is the line shown under an idea: how many of the household's
// total ingredients it uses, then what is missing, in the order given.
// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of
// 5 · missing: 1 lime, cumin" when some are. With nothing missing the
// second part is "nothing missing", so an idea using all 5 and needing
// nothing else prints "Uses all 5 · nothing missing".
func IdeaSummary(uses, missing []string, total int) string {
used := fmt.Sprintf("Uses %d of %d", len(uses), total)
if len(uses) == total {
used = fmt.Sprintf("Uses all %d", total)
}
if len(missing) == 0 {
return used + " · nothing missing"
}
return used + " · missing: " + strings.Join(missing, ", ")
}
// ideasFromIngredients is the skill. It holds its input because
// post-processing compares the output with it.
type ideasFromIngredients struct {
in IdeasRequest
}
var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{}
func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" }
func (ideasFromIngredients) Version() int { return 1 }
func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout }
// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl.
type ideasPrompt struct {
// Count is how many ideas to ask for.
Count int
QuickOnly bool
}
// PromptInput puts the ingredients, already cleaned by
// IdeasFromIngredients, in the household context block.
func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) {
return harness.PromptInput{
Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly},
Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}},
}, nil
}
func (ideasFromIngredients) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"uses": {"type": "array", "items": {"type": "string"}},
"missing": {"type": "array", "items": {"type": "string"}},
"est_minutes": {"type": "integer"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"search_query": {"type": "string"}
},
"required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"],
"additionalProperties": false
}
}
},
"required": ["ideas"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes an idea, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (i *Idea) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil {
return fmt.Errorf("idea %s", err)
}
type plain Idea
return json.Unmarshal(data, (*plain)(i))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *Ideas) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "ideas"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain Ideas
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: no ideas, too
// many, or one that is malformed. A well-formed idea that breaks a rule is
// dropped by PostProcess instead.
func (ideasFromIngredients) Validate(out Ideas) []string {
var problems []string
switch n := len(out.Ideas); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested))
case n > IdeasRequested:
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested))
}
for i, idea := range out.Ideas {
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
}
if !slices.Contains(methods, idea.Method) {
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
}
if idea.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
}
if !slices.Contains(difficulties, idea.Difficulty) {
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess corrects every idea against the household's ingredients and
// moves each that breaks a rule to Dropped. Uses keeps only entries whose
// normalized form (harness.Normalize) is that of a listed ingredient,
// written as the household wrote it, once each and in the household's
// order; anything else the model claimed is removed, never trusted.
// Missing loses blank entries, repeats and anything the household listed.
// An idea is dropped when it repeats an earlier idea or uses none of the
// household's ingredients. The summary line is computed from the
// corrected lists.
func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) {
listed := map[string]int{}
for i, ing := range s.in.Ingredients {
listed[harness.Normalize(ing)] = i
}
result := Ideas{Ideas: []Idea{}}
for _, idea := range out.Ideas {
idea = Idea{
Name: oneLine(idea.Name),
Uses: s.uses(idea.Uses, listed),
Missing: missing(idea.Missing, listed),
EstMinutes: idea.EstMinutes,
Method: idea.Method,
Difficulty: idea.Difficulty,
SearchQuery: oneLine(idea.SearchQuery),
}
if why := dropIdea(idea, result.Ideas); why != "" {
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
continue
}
idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients))
result.Ideas = append(result.Ideas, idea)
}
return result, nil
}
// uses returns the household's ingredients that claimed names, as the
// household wrote them and in its order. A claimed entry that names none
// of them is left out.
func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string {
used := make([]bool, len(s.in.Ingredients))
for _, c := range claimed {
if i, ok := listed[harness.Normalize(c)]; ok {
used[i] = true
}
}
out := []string{}
for i, ing := range s.in.Ingredients {
if used[i] {
out = append(out, ing)
}
}
return out
}
// missing returns claimed on one line each, in order, without blank
// entries, repeats or any the household listed.
func missing(claimed []string, listed map[string]int) []string {
out := []string{}
seen := map[string]bool{}
for _, c := range claimed {
c = oneLine(c)
n := harness.Normalize(c)
if n == "" || seen[n] {
continue
}
if _, ok := listed[n]; ok {
continue
}
seen[n] = true
out = append(out, c)
}
return out
}
// dropIdea returns why idea must be dropped, or "" when it may be kept.
func dropIdea(idea Idea, kept []Idea) string {
for _, k := range kept {
if harness.Similar(idea.Name, k.Name) {
return fmt.Sprintf("repeats the idea %q", k.Name)
}
}
if len(idea.Uses) == 0 {
return "uses none of the household's ingredients"
}
return ""
}
@@ -0,0 +1,269 @@
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)
}
}
}
+1
View File
@@ -6,3 +6,4 @@ normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
@@ -0,0 +1,25 @@
{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}}
{{define "instructions" -}}
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
{{- if .QuickOnly}}
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.
{{- end}}
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
{{- end}}
{{define "user" -}}
Suggest {{.Count}} dinner ideas from the ingredients on hand.
{{- end}}
@@ -0,0 +1,94 @@
## Instructions
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
## Household context
### Ingredients on hand
- Chicken thighs
- rice
- cilantro
- black beans
- red onion
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"uses": {
"type": "array",
"items": {
"type": "string"
}
},
"missing": {
"type": "array",
"items": {
"type": "string"
}
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"uses",
"missing",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -0,0 +1 @@
Suggest 8 dinner ideas from the ingredients on hand.
+297
View File
@@ -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,
}
}
+428
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+53
View File
@@ -0,0 +1,53 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
)
// maxIdeasBody bounds a POST /ideas/from-ingredients body.
const maxIdeasBody = 16 << 10
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
// fields are required.
type ideasFromIngredients struct {
Ingredients []string `json:"ingredients"`
QuickOnly *bool `json:"quick_only"`
}
// postIdeasFromIngredients starts finding ideas from the ingredients on
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
// without waiting for the job. An ingredient list the household must
// correct is 400.
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
switch {
case body.Ingredients == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
return
case body.QuickOnly == nil:
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
return
}
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
var invalid *ideas.InvalidError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
return
case err != nil:
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusAccepted, started)
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"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 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)
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+8 -2
View File
@@ -16,6 +16,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -55,12 +56,13 @@ type RouteMounter interface {
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves
// /api/v1/integrations/google; ideasSvc serves POST
// /api/v1/ideas/from-ingredients; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -85,6 +87,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
if ideasSvc == nil {
return nil, errors.New("server: nil ideas service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -158,6 +163,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+42 -11
View File
@@ -25,6 +25,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
@@ -54,7 +55,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -190,6 +191,27 @@ func newTestSuggest(t *testing.T) *suggest.Service {
return svc
}
// newTestIdeas returns an ideas service with its own unstarted job runner
// and a generator that always fails.
func newTestIdeas(t *testing.T) *ideas.Service {
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(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
return skills.Ideas{}, errors.New("no model in this test")
}
svc, err := ideas.NewService(g, fail, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestPlan returns a plan service over a migrated temp database, with
// its own unstarted job runner and a drafter that always fails.
func newTestPlan(t *testing.T) *plan.Service {
@@ -287,7 +309,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -296,7 +318,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -305,7 +327,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -314,7 +336,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -323,7 +345,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -332,7 +354,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -341,7 +363,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
@@ -350,16 +372,25 @@ func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
func TestNewRouterRequiresIdeas(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil ideas service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
@@ -368,7 +399,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}