From 290e3d3cd302d6262f2ba5568296a3b532e80684 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 20:25:41 -0400 Subject: [PATCH] Add quick_ideas skill and job for Quick dinners (Feature 18) quick_ideas v1 suggests Quick dinners of one kind, crockpot or under30, from the household context, never returning a name already shown: the exclude list is checked against normalized names with harness.Similar. POST /api/v1/ideas/quick {kind, exclude[]} validates both fields (no default kind) and answers 202 {jobId}; the quick_ideas job asks for 12 ideas, grounds them through the same GroundEach rounds as ideas_from_ingredients and keeps up to 6 whose grounded recipe is of the kind (crockpot: method crockpot; under30: minutes_total <= 30), so the model's estimate never decides. Fewer survivors make a shorter page, never a padded one. The grounding loop, failure clearing and failed-result view are now shared generic helpers used by both idea jobs rather than a second copy. Maestro task 243a6812-f52b-4215-a4a2-8eb599914f7e. Co-Authored-By: Claude Opus 5.5 --- cmd/server/main.go | 16 +- docs/ARCHITECTURE.md | 35 ++ internal/ai/skills/prompts/checksums.txt | 1 + .../ai/skills/prompts/quick_ideas.v1.tmpl | 34 ++ internal/ai/skills/quick_ideas.go | 297 ++++++++++++ internal/ai/skills/quick_ideas_test.go | 235 ++++++++++ .../quick_ideas_crockpot.system.golden | 93 ++++ .../testdata/quick_ideas_crockpot.user.golden | 1 + .../quick_ideas_under30.system.golden | 93 ++++ .../testdata/quick_ideas_under30.user.golden | 1 + internal/ideas/ideas.go | 425 ++++++++++++++---- internal/ideas/ideas_test.go | 27 +- internal/ideas/quick_test.go | 400 +++++++++++++++++ internal/server/ideas.go | 44 +- internal/server/ideas_test.go | 17 +- internal/server/quickideas_test.go | 181 ++++++++ internal/server/server.go | 3 +- internal/server/server_test.go | 7 +- 18 files changed, 1800 insertions(+), 110 deletions(-) create mode 100644 internal/ai/skills/prompts/quick_ideas.v1.tmpl create mode 100644 internal/ai/skills/quick_ideas.go create mode 100644 internal/ai/skills/quick_ideas_test.go create mode 100644 internal/ai/skills/testdata/quick_ideas_crockpot.system.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_crockpot.user.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_under30.system.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_under30.user.golden create mode 100644 internal/ideas/quick_test.go create mode 100644 internal/server/quickideas_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 41a1fd4..53df18c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -139,7 +139,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - ideasSvc, err := ideas.NewService(grounder, ideaFinder(ai, guard), runner, logger) + ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger) if err != nil { return err } @@ -287,7 +287,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds // starts (dinners.NewService registers dinners.KindGroundDinner and // suggest.NewService suggest.KindSuggestionBatch, plan.NewService // plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun, -// ideas.NewService ideas.KindFromIngredients). +// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) if err != nil { @@ -356,6 +356,18 @@ func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator { } } +// quickIdeaFinder runs quick_ideas with the AI settings current at each +// run, sharing guard. +func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator { + return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.QuickIdeaList{}, err + } + return skills.QuickIdeas(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) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e4ffb9c..b22453f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -338,6 +338,31 @@ automatic** (D-021). read, however it failed (including `interrupted by restart` and an `internal_error` whose clearing write failed). Before it fails it also publishes its result again with no ideas, to tidy what is stored. +- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick + {kind, exclude[]}` (both required; `exclude` is `[]` on a first page) + checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`, + no default — and `exclude` with `skills.CleanExclude` — at most 60 + names, each non-blank, at most 120 characters, with a non-empty + `harness.Normalize` form no earlier name shares — answering 400 + `invalid_request` otherwise, then enqueues a `quick_ideas` job + (service-only) and answers 202 `{jobId}` without waiting. The job reads + the household context (`harness.BuildContext`, budget 16000), runs + `quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the + normalized excluded names in order), then grounds its ideas exactly as + the ingredients job does (the same `Grounder.GroundEach` rounds, model + order and failure codes) until 6 are kept or none are left. The kind is + enforced on the grounded recipe, never the model's estimate + (`ideas.OfQuickKind`): `crockpot` keeps method `crockpot` + (`normalize_recipe` writes a slow cooker as `crockpot`), `under30` + keeps a known `minutes_total` ≤ 30; every other idea is dropped. The + model is asked for 12, more than a page, so dropped ideas can be + replaced; when fewer than 6 survive the page shows only those, never + padded with an idea not of the kind. The job publishes `{stage, kind, + exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id, + photo_url, source_url, site_name}], dropped}` at each stage and as each + idea is kept, fails with the same reasons, and a failed job shows no + ideas through the same `FailedResult` view. "↻ Show me more" posts + again with every name the tab has shown so far in `exclude`. `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}], @@ -516,6 +541,16 @@ automatic** (D-021). 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. +- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the + household context, a kind (`crockpot` or `under30`, which picks the + prompt's rule) and the names already shown (the "Already shown" context + section), asks for 12 ideas `{name, est_minutes, method, difficulty, + search_query}` at the household temperature. Malformed replies (a + missing field, a bad enum, an empty name, more than 12 ideas) get the + one repair turn. An idea repeating an excluded name or an earlier idea + (`harness.Similar`, on the normalized names) is dropped into `Dropped`, + so an excluded name is never returned. Whether an idea is of the kind + is left to the grounded recipe (see Quick dinners above). - **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 diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index d3053b1..22d188f 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -7,3 +7,4 @@ suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924 +quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05 diff --git a/internal/ai/skills/prompts/quick_ideas.v1.tmpl b/internal/ai/skills/prompts/quick_ideas.v1.tmpl new file mode 100644 index 0000000..95fd954 --- /dev/null +++ b/internal/ai/skills/prompts/quick_ideas.v1.tmpl @@ -0,0 +1,34 @@ +{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}} +{{define "instructions" -}} +You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log. +{{- if .Crockpot}} + +Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot". +{{- else}} + +Every idea must take 30 minutes or less from starting prep to the table. +{{- end}} + +Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. 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. +- 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 (an example only, not about this household): +{{- if .Crockpot}} +{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"} +{{- else}} +{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"} +{{- end}} +{{- end}} +{{define "user" -}} +{{- if .Crockpot -}} +Suggest {{.Count}} crock pot dinner ideas. +{{- else -}} +Suggest {{.Count}} dinner ideas ready in 30 minutes or less. +{{- end}} +{{- end}} diff --git a/internal/ai/skills/quick_ideas.go b/internal/ai/skills/quick_ideas.go new file mode 100644 index 0000000..039ad97 --- /dev/null +++ b/internal/ai/skills/quick_ideas.go @@ -0,0 +1,297 @@ +package skills + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick +// dinners screen. +const ( + // QuickCrockpot is a dinner cooked in a crock pot (slow cooker). + QuickCrockpot = "crockpot" + // QuickUnder30 is a dinner 30 minutes or less from prep to table. + QuickUnder30 = "under30" +) + +// QuickKinds are every kind of Quick dinner, in the screen's tab order. +var QuickKinds = []string{QuickCrockpot, QuickUnder30} + +const ( + // QuickIdeasRequested is how many ideas quick_ideas asks the model + // for: more than a page holds, since validation, the exclude list, + // grounding and the kind's check drop some. + QuickIdeasRequested = 12 + // QuickIdeasKept is the most ideas one page of Quick dinners shows. + QuickIdeasKept = 6 + // MaxExclude is the most names already shown a request may exclude. + MaxExclude = 60 + // ExcludeLimit is the most characters one excluded name may have. + ExcludeLimit = 120 +) + +// QuickIdeasRequest is what quick_ideas reads. +type QuickIdeasRequest struct { + // Context is the household context (see harness.BuildContext): the + // ideas are picked from the household's tastes. + Context harness.HouseholdContext + // Kind is QuickCrockpot or QuickUnder30. It is a hint to the model: + // the caller enforces it after grounding, from the grounded recipe. + Kind string + // Exclude are the names already shown to the household, as + // CleanExclude returns them. No idea repeating one is returned. + Exclude []string +} + +// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty +// are the model's estimates until the idea is grounded in a recipe. +type QuickIdea struct { + Name string `json:"name"` + 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"` +} + +// QuickIdeaList is quick_ideas' output. +type QuickIdeaList struct { + // Ideas are the ideas that passed every rule, in the model's order. + Ideas []QuickIdea `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:"-"` +} + +// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no +// excluded name nor each other (harness.Similar, on the normalized +// names); the rest are in Dropped. Whether an idea is of in.Kind is not +// decided here: the model's estimates never decide it, the grounded +// recipe does. scope names the request (see harness.Run; QuickIdeasScope). +// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses, +// fails before any LLM call. +func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) { + if err := CheckQuickKind(in.Kind); err != nil { + return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err) + } + exclude, err := CleanExclude(in.Exclude) + if err != nil { + return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err) + } + in.Exclude = exclude + return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in) +} + +// QuickIdeasScope is the harness scope of a request: its kind and its +// normalized excluded names, in order, so two requests share a run only +// when they ask for the same page. +func QuickIdeasScope(in QuickIdeasRequest) string { + parts := make([]string, len(in.Exclude)) + for i, name := range in.Exclude { + parts[i] = harness.Normalize(name) + } + return "kind=" + in.Kind + ";" + strings.Join(parts, "|") +} + +// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There +// is no default kind. +func CheckQuickKind(kind string) error { + if !slices.Contains(QuickKinds, kind) { + return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", ")) + } + return nil +} + +// CleanExclude returns the excluded names with each on one line, never +// nil, or why they cannot be used: there are more than MaxExclude, or a +// name is blank, longer than ExcludeLimit characters, has nothing left +// once normalized (harness.Normalize), or normalizes the same as an +// earlier one. An empty list, the first page's, is valid. +func CleanExclude(list []string) ([]string, error) { + if len(list) > MaxExclude { + return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude) + } + out := make([]string, len(list)) + seen := map[string]string{} + for i, name := range list { + name = oneLine(name) + switch { + case name == "": + return nil, fmt.Errorf("excluded name %d is blank", i+1) + case utf8.RuneCountInString(name) > ExcludeLimit: + return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit) + } + n := harness.Normalize(name) + if n == "" { + return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name) + } + if first, ok := seen[n]; ok { + return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first) + } + seen[n] = name + out[i] = name + } + return out, nil +} + +// quickIdeas is the skill. It holds its input because post-processing +// compares the output with it. +type quickIdeas struct { + in QuickIdeasRequest +} + +var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{} + +func (quickIdeas) Name() string { return "quick_ideas" } + +func (quickIdeas) Version() int { return 1 } + +func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout } + +// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl. +type quickIdeasPrompt struct { + // Count is how many ideas to ask for. + Count int + // Crockpot is set for QuickCrockpot, unset for QuickUnder30. + Crockpot bool +} + +// PromptInput puts the household context, then the names already shown, +// in the household context block. +func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) { + if err := CheckQuickKind(in.Kind); err != nil { + return harness.PromptInput{}, err + } + sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude}) + return harness.PromptInput{ + Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot}, + Context: sections, + }, nil +} + +func (quickIdeas) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"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", "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 *QuickIdea) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil { + return fmt.Errorf("idea %s", err) + } + type plain QuickIdea + return json.Unmarshal(data, (*plain)(i)) +} + +// UnmarshalJSON decodes the output, requiring its one field as the schema +// does. +func (out *QuickIdeaList) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "ideas"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain QuickIdeaList + 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 (quickIdeas) Validate(out QuickIdeaList) []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", QuickIdeasRequested)) + case n > QuickIdeasRequested: + problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested)) + } + 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 puts every idea's text on one line and moves each that +// repeats an excluded name or an earlier idea (harness.Similar, which +// compares normalized names) to Dropped. The model's method and minutes +// are kept as estimates and never decide the kind. +func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) { + result := QuickIdeaList{Ideas: []QuickIdea{}} + for _, idea := range out.Ideas { + idea = QuickIdea{ + Name: oneLine(idea.Name), + EstMinutes: idea.EstMinutes, + Method: idea.Method, + Difficulty: idea.Difficulty, + SearchQuery: oneLine(idea.SearchQuery), + } + if why := s.drop(idea, result.Ideas); why != "" { + result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why}) + continue + } + result.Ideas = append(result.Ideas, idea) + } + return result, nil +} + +// drop returns why idea must be dropped, or "" when it may be kept. +func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string { + for _, name := range s.in.Exclude { + if harness.Similar(idea.Name, name) { + return fmt.Sprintf("repeats %q, already shown", name) + } + } + for _, k := range kept { + if harness.Similar(idea.Name, k.Name) { + return fmt.Sprintf("repeats the idea %q", k.Name) + } + } + return "" +} diff --git a/internal/ai/skills/quick_ideas_test.go b/internal/ai/skills/quick_ideas_test.go new file mode 100644 index 0000000..42512c4 --- /dev/null +++ b/internal/ai/skills/quick_ideas_test.go @@ -0,0 +1,235 @@ +package skills + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// quickTastes is the household context of the quick_ideas tests. +var quickTastes = harness.HouseholdContext{ + Today: date(2026, 10, 8), + Timezone: "America/New_York", + Regulars: []harness.RegularDinner{ + {Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true}, + {Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30}, + }, + QuestLog: []string{"Pad Thai"}, +} + +// quickIdea renders one idea of a quick_ideas reply. +func quickIdea(name, method string, minutes int) string { + return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`, + name, minutes, method, strings.ToLower(name)+" recipe") +} + +// quick runs QuickIdeas on in with f and checks f was called exactly +// calls times, each at the household temperature. +func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) { + t.Helper() + out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(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 != "quick_ideas" { + t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema) + } + } + return out, reqs, err +} + +func TestQuickIdeasPrompt(t *testing.T) { + for _, tc := range []struct { + kind, golden, user, rule string + }{ + {QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.", + `Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`}, + {QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.", + "Every idea must take 30 minutes or less from starting prep to the table."}, + } { + t.Run(tc.kind, func(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25)))) + in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}} + _, reqs, err := quick(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + system, user := reqs[0].System, reqs[0].Messages[0].Content + golden(t, tc.golden+".system.golden", system) + golden(t, tc.golden+".user.golden", user) + for _, part := range []string{ + "## Household context", + "### Regulars\n- Beef Chili", + "### Quest log\n- Pad Thai", + "### Already shown\n- Beef Stew\n- Shrimp Tacos", + tc.rule, + "## Output contract", + } { + if !strings.Contains(system, part) { + t.Errorf("system prompt lacks %q", part) + } + } + if user != tc.user { + t.Errorf("user message = %q, want %q", user, tc.user) + } + }) + } +} + +func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(ideasReply( + // The excluded names, spelled another way: the normalized name + // decides. + quickIdea("Easy Beef Stew!", "crockpot", 300), + quickIdea("shrimp-tacos", "stovetop", 20), + quickIdea("Pulled Pork", "crockpot", 480), + // A repeat of an idea already kept. + quickIdea("The Pulled Pork", "crockpot", 420), + quickIdea("Chicken Fried Rice", "stovetop", 25), + ))) + in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}} + out, _, err := quick(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, i := range out.Ideas { + kept = append(kept, i.Name) + } + // Chicken Fried Rice is not a crock pot dinner, but the grounded + // recipe, not the model's estimate, decides that later. + if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) { + t.Fatalf("ideas = %q, want %q", kept, want) + } + want := []DroppedIdea{ + {Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`}, + {Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`}, + {Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`}, + } + if !reflect.DeepEqual(out.Dropped, want) { + t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want) + } +} + +func TestQuickIdeasRepairsMalformedReply(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0)))) + f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480)))) + out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 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 TestQuickIdeasRefusesTooManyIdeas(t *testing.T) { + var f llm.Fake + var many []string + for i := range QuickIdeasRequested + 1 { + many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20)) + } + f.QueueGenerate(reply(ideasReply(many...))) + f.QueueGenerate(reply(ideasReply(many...))) + if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") { + t.Fatalf("err = %v, want a bad output naming the limit", err) + } +} + +func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) { + for _, in := range []QuickIdeasRequest{ + {Context: quickTastes, Kind: ""}, + {Context: quickTastes, Kind: "quick"}, + {Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}}, + } { + var f llm.Fake + if _, _, err := quick(t, &f, in, 0); err == nil { + t.Fatalf("%+v was accepted", in) + } + } +} + +func TestCleanExclude(t *testing.T) { + got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"}) + if err != nil { + t.Fatal(err) + } + if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) { + t.Fatalf("CleanExclude = %q, want %q", got, want) + } + if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 { + t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err) + } + tooMany := make([]string, MaxExclude+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("dish %d", i) + } + for _, tc := range []struct { + name string + in []string + want string + }{ + {"too many", tooMany, "exclude at most 60"}, + {"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"}, + {"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"}, + {"filler only", []string{"Easy Recipe"}, "names no dinner"}, + {"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} + +func TestCheckQuickKind(t *testing.T) { + for _, kind := range QuickKinds { + if err := CheckQuickKind(kind); err != nil { + t.Errorf("CheckQuickKind(%q) = %v", kind, err) + } + } + for _, kind := range []string{"", "Crockpot", "under_30", "quick"} { + if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") { + t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err) + } + } +} + +func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) { + a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}}) + if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b { + t.Errorf("scopes of the same request differ: %q, %q", a, b) + } + for _, other := range []QuickIdeasRequest{ + {Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}}, + {Kind: QuickUnder30}, + {Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}, + } { + if QuickIdeasScope(other) == a { + t.Errorf("%+v shares the scope %q", other, a) + } + } +} diff --git a/internal/ai/skills/testdata/quick_ideas_crockpot.system.golden b/internal/ai/skills/testdata/quick_ideas_crockpot.system.golden new file mode 100644 index 0000000..a4aafcc --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_crockpot.system.golden @@ -0,0 +1,93 @@ +## Instructions +You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log. + +Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot". + +Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. 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. +- 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 (an example only, not about this household): +{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"} + +## Household context +### Today +- Thursday 2026-10-08 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min, favorite) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min) + +### Quest log +- Pad Thai + +### Passed, oldest first +(none) + +### Recent plans +(none) + +### Already shown +- Beef Stew +- Shrimp Tacos + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "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", + "est_minutes", + "method", + "difficulty", + "search_query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "ideas" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_crockpot.user.golden b/internal/ai/skills/testdata/quick_ideas_crockpot.user.golden new file mode 100644 index 0000000..a60bedd --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_crockpot.user.golden @@ -0,0 +1 @@ +Suggest 12 crock pot dinner ideas. \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_under30.system.golden b/internal/ai/skills/testdata/quick_ideas_under30.system.golden new file mode 100644 index 0000000..28824f0 --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_under30.system.golden @@ -0,0 +1,93 @@ +## Instructions +You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log. + +Every idea must take 30 minutes or less from starting prep to the table. + +Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. 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. +- 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 (an example only, not about this household): +{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"} + +## Household context +### Today +- Thursday 2026-10-08 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min, favorite) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min) + +### Quest log +- Pad Thai + +### Passed, oldest first +(none) + +### Recent plans +(none) + +### Already shown +- Beef Stew +- Shrimp Tacos + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "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", + "est_minutes", + "method", + "difficulty", + "search_query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "ideas" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_under30.user.golden b/internal/ai/skills/testdata/quick_ideas_under30.user.golden new file mode 100644 index 0000000..4d59f6a --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_under30.user.golden @@ -0,0 +1 @@ +Suggest 12 dinner ideas ready in 30 minutes or less. \ No newline at end of file diff --git a/internal/ideas/ideas.go b/internal/ideas/ideas.go index d55b922..49743f4e 100644 --- a/internal/ideas/ideas.go +++ b/internal/ideas/ideas.go @@ -1,10 +1,12 @@ -// 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. A job that keeps no idea, or fails, shows no idea: -// nothing is made up instead (D-017). Ideas are not stored as dinners: the -// household acts on one later. +// Package ideas finds dinner ideas for Cook from what I have and Quick +// dinners: from a list of ingredients on hand, ideas_from_ingredients +// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each +// is grounded with the recipe grounding pipeline (dropping those no recipe +// grounds, and Quick dinners whose grounded recipe is not of the kind), and +// the ideas kept are reported as a job's result, which fills as each idea +// grounds. A job that keeps no idea, or fails, shows no idea: nothing is +// made up instead (D-017). Ideas are not stored as dinners: the household +// acts on one later. package ideas import ( @@ -14,15 +16,27 @@ import ( "fmt" "log/slog" "slices" + "time" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "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" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) -// KindFromIngredients is the job that finds ideas from ingredients. -const KindFromIngredients = "ideas_from_ingredients" +// Job kinds. +const ( + // KindFromIngredients is the job that finds ideas from ingredients. + KindFromIngredients = "ideas_from_ingredients" + // KindQuick is the job that finds a page of Quick dinner ideas. + KindQuick = "quick_ideas" +) + +// ContextBudget is the character budget of the household context +// quick_ideas is given (see harness.BuildContext). +const ContextBudget = 16000 // QuickMinutes is the most minutes from prep to table of a Quick dinner // not cooked in a crock pot. @@ -38,25 +52,26 @@ const ( // 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: ideas_from_ingredients or quick_ideas, 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" - // ReasonNoIdeasFound: every idea was dropped, by - // ideas_from_ingredients' rules, because no recipe grounds it, or, for - // a Quick only request, because its grounded recipe is not a Quick - // dinner. + // ReasonNoIdeasFound: every idea was dropped, by the skill's rules, + // because no recipe grounds it, or because its grounded recipe is not + // a Quick dinner (a Quick only request) or not of the kind asked for (a + // Quick dinners request). ReasonNoIdeasFound = "no_ideas_found" ) -// Reasons are every failure reason of the job. +// Reasons are every failure reason of both jobs. var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound} // InvalidError is a request the household must correct, such as an empty -// ingredient list. +// ingredient list or an unknown kind of Quick dinner. type InvalidError struct { Message string } @@ -68,42 +83,68 @@ func (e *InvalidError) Error() string { return e.Message } // with the household's harness.Config. type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) +// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in). +// In production it is skills.QuickIdeas with the household's +// harness.Config. +type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) + // Service starts and runs idea jobs. type Service struct { + st *store.Store grounder *recipes.Grounder generate Generator + quick QuickGenerator logger *slog.Logger job *jobs.Kind[FromIngredientsJob] + quickJob *jobs.Kind[QuickJob] } -// 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) { +// NewService returns a service that reads the household context from st, +// finds ideas with generate and Quick dinner ideas with quick, grounds +// them with grounder and registers the KindFromIngredients and KindQuick +// jobs on runner, which must not have started yet. The jobs are started +// only by StartFromIngredients and StartQuick, which check the request +// first. +func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { switch { + case st == nil: + return nil, errors.New("ideas: nil store") case grounder == nil: return nil, errors.New("ideas: nil grounder") case generate == nil: return nil, errors.New("ideas: nil generator") + case quick == nil: + return nil, errors.New("ideas: nil quick 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...) + s := &Service{st: st, grounder: grounder, generate: generate, quick: quick, logger: logger} + var err error + if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil { + return nil, err + } + if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil { + return nil, err + } + return s, nil +} + +// register registers the service-only job name, run by run, whose failed +// jobs show their result through failed. +func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) { + kind, err := jobs.Register(runner, name, run, Reasons...) if err != nil { return nil, err } if err := kind.ServiceOnly(); err != nil { return nil, err } - if err := kind.FailedResult(withoutIdeas); err != nil { + if err := kind.FailedResult(failed); err != nil { return nil, err } - s.job = kind - return s, nil + return kind, nil } // FromIngredientsJob is the KindFromIngredients payload: the ingredients @@ -195,31 +236,54 @@ func IsQuick(method string, minutes *int) bool { // 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. -// Before the job fails, its result is published again with no ideas, to -// tidy the stored result. That write is not what keeps a failed job from -// showing ideas, since it may fail and a job interrupted by restart never -// makes it: withoutIdeas, applied whenever a failed job is read, is. +// Before the job fails, its result is published again with no ideas (see +// finish). 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 { - if ctx.Err() != nil { - return Result{}, err - } - var failure *jobs.Failure - if 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) - } - if len(res.Ideas) > 0 { - res.Ideas = []Idea{} - if perr := publish(res); perr != nil { - return Result{}, fmt.Errorf("ideas: clear failed job's ideas: %w", perr) - } - } - return Result{}, err + return finish(ctx, s.logger, "ideas from ingredients", res, err, publish) +} + +// shownResult is a job result that shows ideas. +type shownResult[R any] interface { + jobs.Public + // shown is how many ideas the result shows. + shown() int + // cleared is the result showing no ideas. + cleared() R +} + +func (r Result) shown() int { return len(r.Ideas) } + +func (r Result) cleared() Result { + r.Ideas = []Idea{} + return r +} + +// finish ends a job whose work returned res and err. A job that failed +// has its result published again with no ideas, to tidy the stored +// result. That write is not what keeps a failed job from showing ideas, +// since it may fail and a job interrupted by restart never makes it: +// withoutIdeas, applied whenever a failed job is read, is. +func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) { + var zero R + if err == nil { + return res, nil } - return res, nil + if ctx.Err() != nil { + return zero, err + } + var failure *jobs.Failure + if errors.As(err, &failure) { + // Only the code is logged: the error may quote a model's or + // search provider's response. + logger.InfoContext(ctx, what+" failed", "reason", failure.Code) + } + if res.shown() > 0 { + if perr := publish(res.cleared()); perr != nil { + return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr) + } + } + return zero, err } // fill does run's work, returning a *jobs.Failure for a failure the @@ -243,34 +307,16 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R if err := publish(res); err != nil { return res, err } - // order[k] is the index in out.Ideas of res.Ideas[k], so each idea is - // put in its place in the model's order as it grounds. - var order []int - for next := 0; len(res.Ideas) < skills.IdeasKept && next < len(out.Ideas); { - first := next - round := out.Ideas[first:min(first+skills.IdeasKept-len(res.Ideas), len(out.Ideas))] - next += len(round) - err := s.ground(ctx, round, func(i int, gr *recipes.Grounded) error { - if gr == nil { - res.Dropped++ - return nil - } - idea := ideaOf(round[i], *gr) - if p.QuickOnly && !idea.Quick { - res.Dropped++ - return nil - } - at := len(order) - for at > 0 && order[at-1] > first+i { - at-- - } - order = slices.Insert(order, at, first+i) - res.Ideas = slices.Insert(res.Ideas, at, idea) - return publish(res) - }) - if err != nil { - return res, err - } + reqs := make([]recipes.GroundRequest, len(out.Ideas)) + for i, idea := range out.Ideas { + reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} + } + err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) { + idea := ideaOf(out.Ideas[i], gr) + return idea, !p.QuickOnly || idea.Quick + }, func() error { return publish(res) }) + if err != nil { + return res, err } if len(res.Ideas) == 0 { return res, &jobs.Failure{Code: ReasonNoIdeasFound} @@ -279,20 +325,58 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R return res, nil } -// ground grounds ideas by their search queries and calls kept, one call at -// a time, as each idea grounds, with its index in ideas and its -// grounding, or nil when no recipe grounds it. Any other grounding -// failure, or kept failing, stops the groundings still running and is -// returned, as a *jobs.Failure when the household may see it. -func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i int, gr *recipes.Grounded) error) error { +// keepGrounded grounds reqs with g in their order, at most the number +// still wanted at a time, until want ideas are shown or none are left. As +// each grounds, keep makes its idea from request i's grounding and says +// whether it is kept: a kept idea is put in *shown in its place in reqs' +// order (an idea that grounds sooner than one before it is shown first, +// and the earlier one is put back in its place when it grounds) and +// publish is called. An idea no recipe grounds, or that keep refuses, +// counts in *dropped. Any other grounding failure, or publish failing, is +// returned (see ground). +func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error { + // order[k] is the index in reqs of (*shown)[k], so each idea is put in + // its place in reqs' order as it grounds. + var order []int + for next := 0; len(*shown) < want && next < len(reqs); { + first := next + round := reqs[first:min(first+want-len(*shown), len(reqs))] + next += len(round) + err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error { + if gr == nil { + *dropped++ + return nil + } + idea, ok := keep(first+i, *gr) + if !ok { + *dropped++ + return nil + } + at := len(order) + for at > 0 && order[at-1] > first+i { + at-- + } + order = slices.Insert(order, at, first+i) + *shown = slices.Insert(*shown, at, idea) + return publish() + }) + if err != nil { + return err + } + } + return nil +} + +// ground grounds reqs with g and calls kept, one call at a time, as each +// request grounds, with its index in reqs and its grounding, or nil when +// no recipe grounds it. Any other grounding failure, or kept failing, +// stops the groundings still running and is returned, as a *jobs.Failure +// when the household may see it. +func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error { groundCtx, cancel := context.WithCancel(ctx) defer cancel() - reqs := make([]recipes.GroundRequest, len(ideas)) - for i, idea := range ideas { - reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} - } var failed error - s.grounder.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) { + g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) { if failed != nil { return } @@ -309,7 +393,7 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i case errors.Is(r.Err, recipes.ErrNormalizeFailed): failed = &jobs.Failure{Code: ReasonAIFailed} default: - failed = fmt.Errorf("ideas: ground %q: %w", ideas[i].Name, r.Err) + failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err) } if failed != nil { cancel() @@ -324,13 +408,12 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i // withoutIdeas is a failed job's stored result as the job shows it: with no // ideas, however the job failed, so a failed job never shows ideas kept // before the failure. -func withoutIdeas(stored json.RawMessage) (json.RawMessage, error) { - var res Result +func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) { + var res R if err := json.Unmarshal(stored, &res); err != nil { return nil, fmt.Errorf("ideas: decode failed job's result: %w", err) } - res.Ideas = []Idea{} - return res.PublicJSON() + return res.cleared().PublicJSON() } // ideaOf is idea as grounded by gr. @@ -350,3 +433,159 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea { SiteName: gr.SiteName, } } + +// QuickJob is the KindQuick payload: the kind of Quick dinner wanted, +// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown +// to the household, as skills.CleanExclude returns them. +type QuickJob struct { + Kind string `json:"kind"` + Exclude []string `json:"exclude"` +} + +// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of +// kind, none repeating a name in exclude, and returns its id. A kind +// skills.CheckQuickKind refuses, with no default, or an exclude list +// skills.CleanExclude refuses, is an *InvalidError, and starts nothing. +func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) { + if err := skills.CheckQuickKind(kind); err != nil { + return Started{}, &InvalidError{Message: "kind: " + err.Error()} + } + names, err := skills.CleanExclude(exclude) + if err != nil { + return Started{}, &InvalidError{Message: "exclude: " + err.Error()} + } + id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names}) + if err != nil { + return Started{}, fmt.Errorf("ideas: enqueue: %w", err) + } + return Started{JobID: id}, nil +} + +// QuickIdea is a grounded Quick dinner idea as the job's result shows it. +// Method, MinutesTotal and Difficulty are the grounded recipe's, never the +// model's estimates, and always make it of the kind asked for (see +// OfQuickKind). +type QuickIdea struct { + Name string `json:"name"` + // Method is stovetop, oven, crockpot, grill or other: crockpot for + // skills.QuickCrockpot. + Method string `json:"method"` + // MinutesTotal is prep to table, null when the recipe does not say: + // never null, and at most QuickMinutes, for skills.QuickUnder30. + MinutesTotal *int `json:"minutes_total"` + // Difficulty is easy, medium or hard. + Difficulty string `json:"difficulty"` + RecipeID string `json:"recipe_id"` + PhotoURL string `json:"photo_url"` + SourceURL string `json:"source_url"` + SiteName string `json:"site_name"` +} + +// QuickResult is the KindQuick result, published as the job goes: its +// stage, the request, the ideas grounded so far in the model's order (as +// in Result), and how many ideas were dropped, by quick_ideas' rules +// (repeating a name already shown or an earlier idea), because no recipe +// grounds them, or because the grounded recipe is not of the kind. +type QuickResult struct { + Stage string `json:"stage"` + Kind string `json:"kind"` + Exclude []string `json:"exclude"` + Ideas []QuickIdea `json:"ideas"` + Dropped int `json:"dropped"` +} + +func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } + +func (r QuickResult) shown() int { return len(r.Ideas) } + +func (r QuickResult) cleared() QuickResult { + r.Ideas = []QuickIdea{} + return r +} + +// OfQuickKind reports whether a recipe cooked by method in minutes (nil +// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot, +// cooked in a crock pot (normalize_recipe writes a slow cooker as +// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from +// prep to table. Any other kind is none. +func OfQuickKind(kind, method string, minutes *int) bool { + switch kind { + case skills.QuickCrockpot: + return method == "crockpot" + case skills.QuickUnder30: + return minutes != nil && *minutes <= QuickMinutes + } + return false +} + +// runQuick runs a KindQuick job: from the household context, it asks +// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than +// a page holds, then grounds them in the model's order, at most the number +// still wanted at a time, until skills.QuickIdeasKept are kept or none are +// left. An idea no recipe grounds is dropped, and so is one whose grounded +// recipe is not of the kind (OfQuickKind): the model's estimate never +// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page +// shows only those: fewer is honest, and it is never padded with an idea +// not of the kind. When every idea is dropped the job fails with +// ReasonNoIdeasFound. Any other grounding failure fails the job, as does +// quick_ideas failing, with no idea made up instead. The result is +// published at each stage and again as each idea is kept, and a failed job +// shows no ideas, as for KindFromIngredients (see finish). +func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) { + res, err := s.fillQuick(ctx, p, publish) + return finish(ctx, s.logger, "quick ideas", res, err, publish) +} + +// fillQuick does runQuick's work as fill does run's. +func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) { + res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}} + if err := publish(res); err != nil { + return res, err + } + hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget) + if err != nil { + return res, fmt.Errorf("ideas: %w", err) + } + in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude} + out, err := s.quick(ctx, skills.QuickIdeasScope(in), in) + if err != nil { + if ctx.Err() != nil { + return res, ctx.Err() + } + return res, &jobs.Failure{Code: ReasonAIFailed} + } + res.Stage = StageGrounding + res.Dropped = len(out.Dropped) + if err := publish(res); err != nil { + return res, err + } + reqs := make([]recipes.GroundRequest, len(out.Ideas)) + for i, idea := range out.Ideas { + reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} + } + err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) { + return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal) + }, func() error { return publish(res) }) + if err != nil { + return res, err + } + if len(res.Ideas) == 0 { + return res, &jobs.Failure{Code: ReasonNoIdeasFound} + } + res.Stage = StageDone + return res, nil +} + +// quickIdeaOf is idea as grounded by gr. +func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea { + return QuickIdea{ + Name: idea.Name, + Method: gr.Method, + MinutesTotal: gr.MinutesTotal, + Difficulty: gr.Difficulty, + RecipeID: gr.RecipeID, + PhotoURL: dinners.PhotoURL(gr.PhotoID), + SourceURL: gr.SourceURL, + SiteName: gr.SiteName, + } +} diff --git a/internal/ideas/ideas_test.go b/internal/ideas/ideas_test.go index 7523fbc..8524e35 100644 --- a/internal/ideas/ideas_test.go +++ b/internal/ideas/ideas_test.go @@ -16,6 +16,7 @@ import ( "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/household" "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" @@ -32,17 +33,21 @@ type env struct { search *recipestest.Search pages *recipestest.Pages images *recipestest.Images - // model answers ideas_from_ingredients; normalize answers - // normalize_recipe while grounding. + // model answers ideas_from_ingredients and quick_ideas; normalize + // answers normalize_recipe while grounding. model *llm.Fake normalize *llm.Fake - // fail, when set, fails ideas_from_ingredients without a model call. + // fail, when set, fails ideas_from_ingredients and quick_ideas without + // a model call. fail error + st *store.Store } -// newEnv returns an ideas service over a migrated store and an unstarted +// newEnv returns an ideas service over a migrated store with the +// household's settings seeded 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 +// ideas_from_ingredients or quick_ideas 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() @@ -60,12 +65,16 @@ func newEnv(t *testing.T) *env { if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { t.Fatal(err) } + if err := household.SeedSettings(ctx, st, "America/New_York"); 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{}, + st: st, } g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize) guard, err := harness.NewGuard(harness.DefaultMaxCalls) @@ -84,12 +93,18 @@ func newEnv(t *testing.T) *env { } return skills.IdeasFromIngredients(ctx, cfg, scope, in) } + quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + if e.fail != nil { + return skills.QuickIdeaList{}, e.fail + } + return skills.QuickIdeas(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) + e.svc, err = NewService(st, g, generate, quick, e.runner, logger) if err != nil { t.Fatal(err) } diff --git a/internal/ideas/quick_test.go b/internal/ideas/quick_test.go new file mode 100644 index 0000000..04bc7d2 --- /dev/null +++ b/internal/ideas/quick_test.go @@ -0,0 +1,400 @@ +package ideas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// quickIdea renders one idea of a quick_ideas reply whose search query is +// its name. +func quickIdea(name, method string, minutes int) string { + return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`, + name, minutes, method, name) +} + +// quickRecorder collects every result a quick job publishes. +type quickRecorder struct { + mu sync.Mutex + published []QuickResult +} + +func (r *quickRecorder) latest() (QuickResult, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.published) == 0 { + return QuickResult{}, false + } + return r.published[len(r.published)-1], true +} + +func (r *quickRecorder) publish(res QuickResult) error { + r.mu.Lock() + defer r.mu.Unlock() + res.Ideas = append([]QuickIdea(nil), res.Ideas...) + r.published = append(r.published, res) + return nil +} + +// runQuick runs the quick job's handler directly, recording each publish. +func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) { + t.Helper() + names, err := skills.CleanExclude(exclude) + if err != nil { + t.Fatal(err) + } + var rec quickRecorder + res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish) + return res, rec.published, err +} + +func quickNames(list []QuickIdea) []string { + out := []string{} + for _, i := range list { + out = append(out, i.Name) + } + return out +} + +func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) { + e := newEnv(t) + // The model estimates every idea under 30 minutes; the grounded + // recipes decide. + e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25) + e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45) + e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30) + e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240) + e.reply( + quickIdea("Chicken Fried Rice", "stovetop", 20), + quickIdea("Beef Stroganoff", "stovetop", 25), + quickIdea("Shrimp Scampi", "stovetop", 20), + quickIdea("Chicken Chili", "crockpot", 25), + ) + res, _, err := e.runQuick(t, skills.QuickUnder30) + if err != nil { + t.Fatal(err) + } + if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want) + } + if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 { + t.Fatalf("result = %+v, want done with 2 dropped", res) + } + for _, i := range res.Ideas { + if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" { + t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i) + } + } +} + +func TestCrockpotDropsStovetopIdeas(t *testing.T) { + e := newEnv(t) + // The model calls every idea a crock pot dinner; only the recipes + // cooked in a slow cooker are. + e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480) + e.recipe(t, "Beef Stew", "Beef Stew", 150) + e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375) + e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25) + e.reply( + quickIdea("Pulled Pork", "crockpot", 480), + quickIdea("Beef Stew", "crockpot", 360), + quickIdea("Chicken Tortilla Soup", "crockpot", 375), + quickIdea("Chicken Fried Rice", "crockpot", 240), + ) + res, _, err := e.runQuick(t, skills.QuickCrockpot) + if err != nil { + t.Fatal(err) + } + if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want) + } + if res.Dropped != 2 { + t.Fatalf("dropped %d, want 2", res.Dropped) + } + for _, i := range res.Ideas { + if i.Method != "crockpot" { + t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method) + } + } + if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 { + t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m) + } +} + +func TestExcludedNamesAreNeverReturned(t *testing.T) { + e := newEnv(t) + // Every idea has a recipe that would ground it as a Quick dinner. + for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} { + e.recipe(t, name, name, 20) + } + e.reply( + quickIdea("Easy Chicken Fried Rice", "stovetop", 20), + quickIdea("shrimp-tacos", "stovetop", 15), + quickIdea("Garlic Shrimp Pasta", "stovetop", 25), + ) + res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos") + if err != nil { + t.Fatal(err) + } + if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 { + t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped) + } + if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) { + t.Errorf("searched %q, want the excluded names never grounded", got) + } + if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) { + t.Errorf("result exclude = %q", res.Exclude) + } + // The model was told what was already shown. + if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") { + t.Errorf("prompt does not list the names already shown:\n%s", system) + } +} + +func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) { + t.Run("twelve ideas, six shown", func(t *testing.T) { + e := newEnv(t) + var reply []string + for i := range skills.QuickIdeasRequested { + name := fmt.Sprintf("Skillet Dish %c", 'A'+i) + e.recipe(t, name, name, 20) + reply = append(reply, quickIdea(name, "stovetop", 20)) + } + e.reply(reply...) + res, _, err := e.runQuick(t, skills.QuickUnder30) + if err != nil { + t.Fatal(err) + } + if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 { + t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept) + } + if got := e.search.Queries(); len(got) != skills.QuickIdeasKept { + t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept) + } + }) + t.Run("over-generated ideas replace dropped ones", func(t *testing.T) { + e := newEnv(t) + var reply []string + // The first six ground too slow, so the next six are grounded to + // fill the page; only three of those are quick. + for i := range skills.QuickIdeasRequested { + name := fmt.Sprintf("Skillet Dish %c", 'A'+i) + minutes := 20 + if i < 6 || i%2 == 1 { + minutes = 50 + } + e.recipe(t, name, name, minutes) + reply = append(reply, quickIdea(name, "stovetop", 20)) + } + e.reply(reply...) + res, _, err := e.runQuick(t, skills.QuickUnder30) + if err != nil { + t.Fatal(err) + } + // Fewer than six survive: the page shows three, never padded. + if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone { + t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want) + } + }) +} + +func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) { + e := newEnv(t) + for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} { + e.recipe(t, name, name, 25) + } + // The first idea's search waits, so the two after it ground first. + gate := make(chan struct{}) + e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate} + e.reply( + quickIdea("Chicken Fried Rice", "stovetop", 20), + quickIdea("Shrimp Scampi", "stovetop", 20), + quickIdea("Garlic Shrimp Pasta", "stovetop", 25), + ) + var rec quickRecorder + type outcome struct { + res QuickResult + err error + } + done := make(chan outcome, 1) + go func() { + res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, rec.publish) + done <- outcome{res, err} + }() + + deadline := time.Now().Add(10 * time.Second) + for { + if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 { + if got := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) { + t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got) + } + if latest.Stage != StageGrounding { + t.Fatalf("stage = %s, want grounding", latest.Stage) + } + break + } + if time.Now().After(deadline) { + t.Fatal("the ideas that grounded were not shown while the first idea was still grounding") + } + time.Sleep(5 * time.Millisecond) + } + close(gate) + out := <-done + if out.err != nil { + t.Fatal(out.err) + } + want := []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} + if got := quickNames(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone { + t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want) + } + + var stages []string + var counts []int + for _, p := range rec.published { + stages = append(stages, p.Stage) + counts = append(counts, len(p.Ideas)) + } + if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) { + t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts) + } +} + +func TestQuickFailures(t *testing.T) { + for _, tc := range []struct { + name string + setup func(t *testing.T, e *env) + code string + }{ + {"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed}, + {"no idea is of the kind", func(t *testing.T, e *env) { + e.recipe(t, "Beef Stew", "Beef Stew", 150) + e.reply(quickIdea("Beef Stew", "crockpot", 360)) + }, ReasonNoIdeasFound}, + {"every idea already shown", func(t *testing.T, e *env) { + e.reply(quickIdea("Pulled Pork", "crockpot", 480)) + }, ReasonNoIdeasFound}, + {"a later round fails after ideas were shown", func(t *testing.T, e *env) { + e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240) + e.recipe(t, "Beef Stew", "Beef Stew", 150) + e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")} + reply := []string{quickIdea("Chicken Chili", "crockpot", 240)} + for i := range 5 { + name := fmt.Sprintf("Mystery Stew %d", i+1) + reply = append(reply, quickIdea(name, "crockpot", 300)) + } + e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...) + }, ReasonSearchFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + e := newEnv(t) + tc.setup(t, e) + _, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork") + var failure *jobs.Failure + if !errors.As(err, &failure) || failure.Code != tc.code { + t.Fatalf("err = %v, want failure %s", err, tc.code) + } + if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone { + t.Fatalf("last publish of a failed job = %+v, want no ideas", last) + } + }) + } +} + +func TestStartQuickRunsTheJob(t *testing.T) { + e := newEnv(t) + e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480) + e.reply(quickIdea("Pulled Pork", "crockpot", 480)) + 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() + }) + + for _, tc := range []struct { + kind string + exclude []string + want string + }{ + {"", nil, `kind: kind "" is not one of crockpot, under30`}, + {"quick", nil, `kind: kind "quick" is not one of crockpot, under30`}, + {skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`}, + } { + _, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude) + var invalid *InvalidError + if !errors.As(err, &invalid) || invalid.Message != tc.want { + t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want) + } + } + + started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "}) + 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 != KindQuick { + t.Fatalf("job = %+v", job) + } + var res QuickResult + if err := json.Unmarshal(job.Result, &res); err != nil { + t.Fatal(err) + } + if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 { + t.Fatalf("result = %+v", res) + } + if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" { + t.Errorf("idea = %+v", got) + } +} + +func TestOfQuickKind(t *testing.T) { + m := func(n int) *int { return &n } + for _, tc := range []struct { + kind, method string + minutes *int + want bool + }{ + {skills.QuickCrockpot, "crockpot", m(480), true}, + {skills.QuickCrockpot, "crockpot", nil, true}, + {skills.QuickCrockpot, "stovetop", m(20), false}, + {skills.QuickUnder30, "stovetop", m(30), true}, + {skills.QuickUnder30, "oven", m(31), false}, + {skills.QuickUnder30, "stovetop", nil, false}, + {skills.QuickUnder30, "crockpot", m(240), false}, + {"quick", "crockpot", m(20), false}, + } { + if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want { + t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want) + } + } + if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) { + t.Errorf("kinds = %q", skills.QuickKinds) + } +} diff --git a/internal/server/ideas.go b/internal/server/ideas.go index d304ddf..edd6c7e 100644 --- a/internal/server/ideas.go +++ b/internal/server/ideas.go @@ -8,7 +8,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" ) -// maxIdeasBody bounds a POST /ideas/from-ingredients body. +// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body. const maxIdeasBody = 16 << 10 // ideasFromIngredients is the POST /ideas/from-ingredients body. Both @@ -51,3 +51,45 @@ func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.Hand writeJSON(w, r, logger, http.StatusAccepted, started) } } + +// ideasQuick is the POST /ideas/quick body. Both fields are required: +// exclude is [] on a first page. +type ideasQuick struct { + Kind *string `json:"kind"` + Exclude []string `json:"exclude"` +} + +// postIdeasQuick starts finding a page of Quick dinner ideas of a kind, +// crockpot or under30, none named in exclude, and answers 202 with the +// jobId that GET /jobs/{id} reports on, without waiting for the job. A +// missing or unknown kind, or an exclude list the household must correct, +// is 400. +func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + switch { + case body.Kind == nil: + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required") + return + case body.Exclude == nil: + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required") + return + } + started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude) + 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 quick ideas", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusAccepted, started) + } +} diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go index 3227743..c08a88c 100644 --- a/internal/server/ideas_test.go +++ b/internal/server/ideas_test.go @@ -18,6 +18,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/recipes" @@ -28,13 +29,16 @@ import ( 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, with its job runner and store. The runner is started when -// start is set. +// ideas_from_ingredients and quick_ideas through the real harness with the +// scripted model and grounds with fake search, pages and images and the +// scripted normalize, with its job runner and store, whose household +// settings are seeded. The 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, *store.Store) { t.Helper() st := newTestStore(t) + if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil { + t.Fatal(err) + } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) runner, err := jobs.NewRunner(st, logger, 1) if err != nil { @@ -54,7 +58,10 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes 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) + quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + return skills.QuickIdeas(ctx, cfg, scope, in) + } + svc, err := ideas.NewService(st, g, generate, quick, runner, logger) if err != nil { t.Fatal(err) } diff --git a/internal/server/quickideas_test.go b/internal/server/quickideas_test.go new file mode 100644 index 0000000..61fce61 --- /dev/null +++ b/internal/server/quickideas_test.go @@ -0,0 +1,181 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" +) + +const quickPath = "/api/v1/ideas/quick" + +// quickRecipe makes the search for name find a page titled title with a +// total time of minutes, and scripts the normalize_recipe reply grounding +// it takes. "Slow Cooker" in title grounds the method as crockpot. +func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) { + t.Helper() + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + search.Results[name] = []recipes.Candidate{{URL: page}} + pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes) + images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1)) + normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes)) +} + +// quickReply scripts one quick_ideas reply holding the ideas named, each +// searched by its name. +func quickReply(model *llm.Fake, ideas ...[3]any) { + list := make([]string, len(ideas)) + for i, idea := range ideas { + list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0]) + } + js := `{"ideas":[` + strings.Join(list, ",") + `]}` + model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) { + return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}}, + &recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}} +} + +func TestPostIdeasQuickRefusesBadRequests(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 }{ + {`{"exclude": []}`, "kind: required"}, + {`{"kind": "crockpot"}`, "exclude: required"}, + {`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`}, + {`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`}, + {`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`}, + {`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"}, + {`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`}, + {`{"kind": 30, "exclude": []}`, "kind: want a JSON string"}, + {`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`}, + } { + rec, body := callDinners(t, h, http.MethodPost, quickPath, 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) + } + } +} + +// The job answers 202 at once and its result fills idea by idea: one +// idea is shown while another still grounds. The kind is checked against +// the grounded recipe and excluded names never come back. +func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := newQuickFakes() + quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25) + quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45) + quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30) + gate := make(chan struct{}) + search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate} + quickReply(model, + [3]any{"Easy Shrimp Tacos", "stovetop", 15}, + [3]any{"Chicken Fried Rice", "stovetop", 20}, + [3]any{"Beef Stroganoff", "stovetop", 25}, + [3]any{"Garlic Shrimp Pasta", "stovetop", 20}, + ) + h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + + rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`) + 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) + } + + // While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows. + deadline := time.Now().Add(10 * time.Second) + for { + _, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "") + if res, _ := job["result"].(map[string]any); res != nil { + if list, _ := res["ideas"].([]any); len(list) == 1 { + if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" { + t.Fatalf("partial job = %v", job) + } + break + } + } + if time.Now().After(deadline) { + t.Fatal("no idea was shown while another still grounded") + } + time.Sleep(5 * time.Millisecond) + } + close(gate) + + job := waitJob(t, h, id) + if job["status"] != "succeeded" || job["kind"] != "quick_ideas" { + t.Fatalf("GET job = %v", job) + } + res := job["result"].(map[string]any) + if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 { + t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res) + } + if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` { + t.Errorf("result exclude = %s", ex) + } + var names []string + for _, i := range res["ideas"].([]any) { + names = append(names, i.(map[string]any)["name"].(string)) + } + if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) { + t.Fatalf("ideas = %q, want %q", names, want) + } + got := res["ideas"].([]any)[1].(map[string]any) + for key, want := range map[string]any{ + "method": "stovetop", + "minutes_total": 30.0, + "site_name": "R Kitchen", + "source_url": "https://r.example/garlic-shrimp-pasta", + } { + if got[key] != want { + t.Errorf("idea %s = %v, want %v", key, got[key], want) + } + } + if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil { + t.Errorf("idea = %v, want a recipe, photo and difficulty", got) + } +} + +func TestFailedQuickJobShowsNoIdeas(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := newQuickFakes() + quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240) + search.Fail = map[string]error{"Lentil Soup": errors.New("boom")} + ideas := [][3]any{{"Chicken Chili", "crockpot", 240}} + for i := range 5 { + ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300}) + } + quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...) + h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed") +} + +func TestJobsEndpointRefusesQuickKind(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": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`) + if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" { + t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 993c5ff..4e3a8df 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -57,7 +57,7 @@ type RouteMounter interface { // /api/v1/integrations/google and its /connect, /callback and /calendars, // PUT /api/v1/integrations/google/calendar and DELETE // /api/v1/integrations/google; ideasSvc serves POST -// /api/v1/ideas/from-ingredients; runner serves +// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick; 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. @@ -164,6 +164,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a 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("/ideas/quick", postIdeasQuick(logger, ideasSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1d35595..859342f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -192,7 +192,7 @@ func newTestSuggest(t *testing.T) *suggest.Service { } // newTestIdeas returns an ideas service with its own unstarted job runner -// and a generator that always fails. +// and generators that always fail. func newTestIdeas(t *testing.T) *ideas.Service { t.Helper() st := newTestStore(t) @@ -205,7 +205,10 @@ func newTestIdeas(t *testing.T) *ideas.Service { 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) + failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + return skills.QuickIdeaList{}, errors.New("no model in this test") + } + svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger) if err != nil { t.Fatal(err) } -- 2.55.0