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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 crock pot dinner ideas.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 dinner ideas ready in 30 minutes or less.
|
||||
Reference in New Issue
Block a user