diff --git a/internal/ai/skills/draft_week.go b/internal/ai/skills/draft_week.go new file mode 100644 index 0000000..b529842 --- /dev/null +++ b/internal/ai/skills/draft_week.go @@ -0,0 +1,328 @@ +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" + // Aliased: the package tests name a fixture household. + hh "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// NoteLimit is the most characters a drafted week's note may have. +const NoteLimit = 160 + +// WeekLength is how many dinners a drafted week holds, one a day from +// Monday to Sunday. +const WeekLength = 7 + +// Candidate kinds: a Regular or a Quest log dinner. +const ( + KindRegular = "regular" + KindQuestLog = "quest_log" +) + +// WeekRequest is what draft_week reads. +type WeekRequest struct { + // Context is the household context (see harness.BuildContext). + Context harness.HouseholdContext + // WeekStart is the Monday of the week to draft, household-local. + WeekStart store.LocalDate + // Pool holds the dinners the week may be drafted from. It must hold at + // least WeekLength dinners, each once. + Pool []Candidate + // LastWeek holds the IDs of the dinners planned in the week before + // WeekStart. They need not be in Pool. + LastWeek []string + // DinnerTime is the household's dinner time, "HH:MM", from which a + // crock-pot dinner's StartBy is computed. + DinnerTime string +} + +// Candidate is one dinner of the pool. +type Candidate struct { + ID string + Name string + // Kind is KindRegular or KindQuestLog. + Kind string + // Method is one of stovetop, oven, crockpot, grill and other, or "" + // when unknown. + Method string + // Minutes from prep to table, or 0 when unknown. + Minutes int +} + +// NotEnoughDinnersError is a pool too small to fill a week. DraftWeek +// returns it before any LLM call. +type NotEnoughDinnersError struct { + // Pool is how many dinners the pool holds. + Pool int +} + +func (e *NotEnoughDinnersError) Error() string { + return fmt.Sprintf("not enough dinners: the pool holds %d, a week needs %d", e.Pool, WeekLength) +} + +// Week is draft_week's output. +type Week struct { + // Days are the week's dinners, Monday to Sunday. + Days []WeekDay `json:"days"` + // Note is a short word on the week, at most NoteLimit characters. It + // may be empty. + Note string `json:"note"` + // Warnings say what the draft did that the rules discourage but allow, + // such as repeating last week's dinners. It is never part of the + // model's reply. + Warnings []string `json:"-"` +} + +// WeekDay is one day's dinner. +type WeekDay struct { + // Date is the household-local date, YYYY-MM-DD. + Date string `json:"date"` + // DinnerID is the ID of a dinner of the pool. + DinnerID string `json:"dinner_id"` + // IsNew is true for a Quest log dinner. It is computed from the pool, + // never part of the model's reply. + IsNew bool `json:"-"` + // StartBy is when a crock-pot dinner must start to be ready at the + // household's dinner time; it is zero for any other dinner and for a + // crock-pot dinner whose minutes are unknown. It is computed, never + // part of the model's reply. + StartBy time.Time `json:"-"` +} + +// DraftWeek runs draft_week v1 on in and returns a week of seven dinners +// from the pool, one a day from Monday to Sunday, none twice. A pool of +// fewer than WeekLength dinners fails with *NotEnoughDinnersError before +// any LLM call, and any other invalid request fails before it too. scope +// names the week (see harness.Run). +func DraftWeek(ctx context.Context, cfg harness.Config, scope string, in WeekRequest) (Week, error) { + s, err := newDraftWeek(in) + if err != nil { + return Week{}, err + } + return harness.Run(ctx, cfg, s, scope, in) +} + +// draftWeek is the skill. It holds its input, the week's dates and the +// pool by ID because validation and post-processing compare the output +// with them. +type draftWeek struct { + in WeekRequest + dates [WeekLength]store.LocalDate + pool map[string]Candidate + zone *time.Location +} + +var _ harness.Skill[WeekRequest, Week] = draftWeek{} + +// newDraftWeek checks in and returns the skill for it. +func newDraftWeek(in WeekRequest) (draftWeek, error) { + pool := map[string]Candidate{} + for i, c := range in.Pool { + if err := c.check(); err != nil { + return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: %w", i+1, err) + } + if _, dup := pool[c.ID]; dup { + return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: id %q is in the pool twice", i+1, c.ID) + } + pool[c.ID] = c + } + if len(pool) < WeekLength { + return draftWeek{}, &NotEnoughDinnersError{Pool: len(pool)} + } + dates, err := hh.WeekDays(in.WeekStart) + if err != nil { + return draftWeek{}, fmt.Errorf("draft_week: %w", err) + } + zone, err := hh.LoadZone(in.Context.Timezone) + if err != nil { + return draftWeek{}, fmt.Errorf("draft_week: %w", err) + } + // The dinner time is checked here so a bad one fails before the LLM + // call rather than in PostProcess. + if _, err := hh.LocalTime(zone, in.WeekStart, in.DinnerTime); err != nil { + return draftWeek{}, fmt.Errorf("draft_week: dinner time: %w", err) + } + return draftWeek{in: in, dates: dates, pool: pool, zone: zone}, nil +} + +// check reports a candidate the pool cannot hold. +func (c Candidate) check() error { + switch { + case strings.TrimSpace(c.ID) == "" || strings.ContainsAny(c.ID, "\r\n"): + return fmt.Errorf("id %q is empty or has a line break", c.ID) + case strings.TrimSpace(c.Name) == "": + return fmt.Errorf("%s has an empty name", c.ID) + case c.Kind != KindRegular && c.Kind != KindQuestLog: + return fmt.Errorf("%s kind %q is not %s or %s", c.ID, c.Kind, KindRegular, KindQuestLog) + case c.Method != "" && !slices.Contains(methods, c.Method): + return fmt.Errorf("%s method %q is not one of %s", c.ID, c.Method, strings.Join(methods, ", ")) + case c.Minutes < 0: + return fmt.Errorf("%s minutes %d is negative", c.ID, c.Minutes) + } + return nil +} + +func (draftWeek) Name() string { return "draft_week" } + +func (draftWeek) Version() int { return 1 } + +func (draftWeek) Timeout() time.Duration { return harness.DefaultTimeout } + +// weekPrompt is the template data of draft_week.v1.tmpl. +type weekPrompt struct { + // Days are the week's dates, Monday to Sunday, e.g. "Mon 2026-10-12". + Days []string +} + +func (s draftWeek) PromptInput(in WeekRequest) (harness.PromptInput, error) { + var days []string + for _, d := range s.dates { + y, m, day := d.Date() + days = append(days, time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Format("Mon")+" "+d.String()) + } + var pool []string + for _, c := range in.Pool { + pool = append(pool, c.line(slices.Contains(in.LastWeek, c.ID))) + } + sections := append(in.Context.Sections(), harness.ContextSection{Title: "Candidate pool", Lines: pool}) + return harness.PromptInput{Data: weekPrompt{Days: days}, Context: sections}, nil +} + +// line renders c as `id "ID": Name (Regular, method, N min, planned last +// week)`, leaving out what is unknown. +func (c Candidate) line(lastWeek bool) string { + attrs := []string{"Regular"} + if c.Kind == KindQuestLog { + attrs = []string{"Quest log"} + } + if c.Method != "" { + attrs = append(attrs, c.Method) + } + if c.Minutes > 0 { + attrs = append(attrs, fmt.Sprintf("%d min", c.Minutes)) + } + if lastWeek { + attrs = append(attrs, "planned last week") + } + return fmt.Sprintf("id %q: %s (%s)", c.ID, oneLine(c.Name), strings.Join(attrs, ", ")) +} + +func (draftWeek) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "draft_week", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "days": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": {"type": "string"}, + "dinner_id": {"type": "string"} + }, + "required": ["date", "dinner_id"], + "additionalProperties": false + } + }, + "note": {"type": "string"} + }, + "required": ["days", "note"], + "additionalProperties": false + }`)} +} + +// UnmarshalJSON decodes a day, requiring each of its fields as the schema +// does. +func (d *WeekDay) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "date", "dinner_id"); err != nil { + return fmt.Errorf("day %s", err) + } + type plain WeekDay + return json.Unmarshal(data, (*plain)(d)) +} + +// UnmarshalJSON decodes the output, requiring each of its fields as the +// schema does. +func (w *Week) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "days", "note"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain Week + return json.Unmarshal(data, (*plain)(w)) +} + +// Validate finds every broken rule: a week that is not exactly the seven +// dates Monday to Sunday in order, a dinner not in the pool or planned +// twice, or a note over NoteLimit characters. +func (s draftWeek) Validate(out Week) []string { + var problems []string + if n := len(out.Days); n != WeekLength { + problems = append(problems, fmt.Sprintf("days has %d entries; give exactly %d, one for each date from %s to %s", + n, WeekLength, s.dates[0], s.dates[WeekLength-1])) + } + first := map[string]int{} // dinner ID -> first day planning it + for i, d := range out.Days { + if i < WeekLength && d.Date != s.dates[i].String() { + problems = append(problems, fmt.Sprintf("day %d date %q is not %s; give the dates %s to %s in order", + i+1, d.Date, s.dates[i], s.dates[0], s.dates[WeekLength-1])) + } + c, ok := s.pool[d.DinnerID] + if !ok { + problems = append(problems, fmt.Sprintf("day %d dinner_id %q is not in the candidate pool; use only ids from it", i+1, d.DinnerID)) + continue + } + if j, dup := first[d.DinnerID]; dup { + problems = append(problems, fmt.Sprintf("day %d dinner_id %q (%s) is also on day %d; plan each dinner at most once", + i+1, d.DinnerID, oneLine(c.Name), j+1)) + continue + } + first[d.DinnerID] = i + } + if n := utf8.RuneCountInString(out.Note); n > NoteLimit { + problems = append(problems, fmt.Sprintf("note has %d characters; keep it to at most %d", n, NoteLimit)) + } + return problems +} + +// PostProcess tidies the note, marks each Quest log dinner new, computes +// each crock-pot dinner's StartBy, and warns when the week repeats more of +// last week's dinners than the pool made necessary. +func (s draftWeek) PostProcess(out Week) (Week, error) { + result := Week{Note: oneLine(out.Note)} + var repeats []string + for i, d := range out.Days { + c := s.pool[d.DinnerID] + day := WeekDay{Date: d.Date, DinnerID: d.DinnerID, IsNew: c.Kind == KindQuestLog} + if c.Method == "crockpot" && c.Minutes > 0 { + dinner, err := hh.LocalTime(s.zone, s.dates[i], s.in.DinnerTime) + if err != nil { + return Week{}, err + } + day.StartBy = dinner.Add(-time.Duration(c.Minutes) * time.Minute) + } + if slices.Contains(s.in.LastWeek, d.DinnerID) { + repeats = append(repeats, oneLine(c.Name)) + } + result.Days = append(result.Days, day) + } + fresh := 0 + for id := range s.pool { + if !slices.Contains(s.in.LastWeek, id) { + fresh++ + } + } + if allowed := max(0, WeekLength-fresh); len(repeats) > allowed { + result.Warnings = append(result.Warnings, fmt.Sprintf("repeats %d of last week's dinners (%s) though the pool needed at most %d", + len(repeats), strings.Join(repeats, ", "), allowed)) + } + return result, nil +} diff --git a/internal/ai/skills/draft_week_test.go b/internal/ai/skills/draft_week_test.go new file mode 100644 index 0000000..f138148 --- /dev/null +++ b/internal/ai/skills/draft_week_test.go @@ -0,0 +1,324 @@ +package skills + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two +// of them planned last week. +var weekOf = WeekRequest{ + Context: harness.HouseholdContext{ + Today: date(2026, 10, 8), + Timezone: "America/New_York", + Regulars: []harness.RegularDinner{ + {Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240}, + {Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true}, + }, + QuestLog: []string{"Pad Thai"}, + Passed: []string{}, + Plans: []harness.PlannedDinner{ + {Date: date(2026, 10, 5), Name: "Chicken Fajitas"}, + {Date: date(2026, 10, 6), Name: "Fish Tacos"}, + }, + }, + WeekStart: date(2026, 10, 12), + Pool: []Candidate{ + {ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240}, + {ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30}, + {ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45}, + {ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"}, + {ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25}, + {ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35}, + {ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"}, + {ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40}, + }, + LastWeek: []string{"fajitas", "tacos", "gone"}, + DinnerTime: "17:30", +} + +// weekDates are the dates of weekOf, Monday to Sunday. +var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} + +// week renders a reply planning ids on dates, in order. +func week(note string, dates []string, ids ...string) string { + var days []string + for i, id := range ids { + days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id)) + } + return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note) +} + +// clean plans no dinner of last week. +var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"} + +// wantWeek is the output for a reply planning ids on weekDates with note. +func wantWeek(note string, ids ...string) Week { + newYork, err := time.LoadLocation("America/New_York") + if err != nil { + panic(err) + } + out := Week{Note: note} + for i, id := range ids { + day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"} + if id == "chili" { + y, m, d := date(2026, 10, 12+i).Date() + day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork) + } + out.Days = append(out.Days, day) + } + return out +} + +// draft runs DraftWeek on in with f and checks f was called exactly calls +// times, each at the household temperature with the draft_week schema. +func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) { + t.Helper() + out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", 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 != "draft_week" { + t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema) + } + } + return out, reqs, err +} + +func assertWeek(t *testing.T, got, want Week) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("output = %+v\nwant %+v", got, want) + } +} + +func TestDraftWeekValid(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...))) + out, reqs, err := draft(t, &f, weekOf, 1) + if err != nil { + t.Fatal(err) + } + assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...)) + if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) { + t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy) + } + + system, user := reqs[0].System, reqs[0].Messages[0].Content + golden(t, "draft_week_valid.system.golden", system) + golden(t, "draft_week_valid.user.golden", user) + for _, part := range []string{ + "## Household context", + "### Candidate pool", + `- id "chili": Beef Chili (Regular, crockpot, 240 min)`, + `- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`, + `- id "salmon": Sheet Pan Salmon (Regular, oven)`, + `- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`, + `- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`, + "## Output contract", + `"dinner_id"`, + } { + if !strings.Contains(system, part) { + t.Errorf("system prompt lacks %q", part) + } + } + if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") { + t.Error("the output contract is not after the candidate pool") + } + want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18." + if user != want { + t.Errorf("user message = %q, want %q", user, want) + } +} + +// repaired checks a first reply is repaired once, quoting it and the +// problems wanted. +func repaired(t *testing.T, first string, want ...string) { + t.Helper() + var f llm.Fake + f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...))) + out, reqs, err := draft(t, &f, weekOf, 2) + if err != nil { + t.Fatal(err) + } + assertWeek(t, out, wantWeek("Fixed.", clean...)) + repair := reqs[1].Messages + if len(repair) != 3 || repair[1].Content != first { + t.Fatalf("repair turn does not quote the first reply: %+v", repair) + } + for _, w := range want { + if !strings.Contains(repair[2].Content, w) { + t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w) + } + } +} + +func TestDraftWeekUnknownIDRepaired(t *testing.T) { + ids := slices.Clone(clean) + ids[2] = "lasagna" + repaired(t, week("A week.", weekDates, ids...), + `day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`) +} + +func TestDraftWeekDuplicateRepaired(t *testing.T) { + ids := slices.Clone(clean) + ids[5] = "chili" + repaired(t, week("A week.", weekDates, ids...), + `day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`) +} + +func TestDraftWeekWrongDatesTwiceFails(t *testing.T) { + sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...) + var f llm.Fake + f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...))) + out, _, err := draft(t, &f, weekOf, 2) + var bad *harness.BadOutputError + if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) { + t.Fatalf("err = %v, want a BadOutputError", err) + } + if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) { + t.Fatalf("repair problems = %q", bad.Repair) + } + if out.Days != nil { + t.Fatalf("output = %+v, want none", out) + } +} + +func TestDraftWeekNotEnoughDinners(t *testing.T) { + small := weekOf + small.Pool = slices.Clone(weekOf.Pool[:6]) + var f llm.Fake + _, _, err := draft(t, &f, small, 0) + var short *NotEnoughDinnersError + if !errors.As(err, &short) || short.Pool != 6 { + t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err) + } + if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" { + t.Fatalf("err = %q", err) + } +} + +func TestDraftWeekLastWeekRepeats(t *testing.T) { + // The pool has six dinners not planned last week, so the week needs one + // of last week's two and may plan it without a warning. + one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"} + both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"} + for name, tc := range map[string]struct { + ids []string + warn []string + }{ + "needed repeat": {one, nil}, + "avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}}, + } { + t.Run(name, func(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...))) + out, _, err := draft(t, &f, weekOf, 1) + if err != nil { + t.Fatal(err) + } + want := wantWeek("A week.", tc.ids...) + want.Warnings = tc.warn + assertWeek(t, out, want) + }) + } +} + +func TestWeekValidate(t *testing.T) { + s, err := newDraftWeek(weekOf) + if err != nil { + t.Fatal(err) + } + reply := func(note string, ids ...string) Week { + var w Week + if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil { + t.Fatal(err) + } + return w + } + if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 { + t.Fatalf("valid output has problems: %q", p) + } + for name, tc := range map[string]struct { + out Week + want []string + }{ + "empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}}, + "six days": {reply("", clean[:6]...), []string{ + "days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18", + }}, + "eight days": { + Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})}, + []string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}, + }, + "long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}}, + "dates out of order": { + Week{Days: []WeekDay{ + {weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}}, + {weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}}, + {weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}}, + {weekDates[6], "fajitas", false, time.Time{}}, + }}, + []string{ + `day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`, + `day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`, + }, + }, + } { + t.Run(name, func(t *testing.T) { + if got := s.Validate(tc.out); !slices.Equal(got, tc.want) { + t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n")) + } + }) + } +} + +func TestDraftWeekRequestRejected(t *testing.T) { + with := func(change func(*WeekRequest)) WeekRequest { + in := weekOf + in.Pool = slices.Clone(weekOf.Pool) + change(&in) + return in + } + for name, tc := range map[string]struct { + in WeekRequest + want string + }{ + "not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }), + "draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"}, + "bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }), + `draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`}, + "bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }), + `draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`}, + "empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }), + `draft_week: pool dinner 3: id " " is empty or has a line break`}, + "id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }), + `draft_week: pool dinner 4: id "chili" is in the pool twice`}, + "bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }), + `draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`}, + } { + t.Run(name, func(t *testing.T) { + var f llm.Fake + if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index baf7690..79ec5ec 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -4,3 +4,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a +draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 diff --git a/internal/ai/skills/prompts/draft_week.v1.tmpl b/internal/ai/skills/prompts/draft_week.v1.tmpl new file mode 100644 index 0000000..ed4bb2e --- /dev/null +++ b/internal/ai/skills/prompts/draft_week.v1.tmpl @@ -0,0 +1,21 @@ +{{/* draft_week drafts the household's weekly plan: one dinner a day, Monday to Sunday, from a candidate pool. */}} +{{define "instructions" -}} +You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context. + +Rules: +- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD. +- Use only dinners from the candidate pool, each by its id exactly as the pool writes it. +- Plan each dinner at most once in the week. +- Avoid the dinners marked "planned last week" whenever the pool has enough others. +- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one. + +Give: +- days: the 7 days, each with date and dinner_id. +- note: one sentence of at most 160 characters about the week. + +One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household): +{"date": "2026-01-05", "dinner_id": "d1"} +{{- end}} +{{define "user" -}} +Draft the week of {{range $i, $d := .Days}}{{if $i}}, {{end}}{{$d}}{{end}}. +{{- end}} diff --git a/internal/ai/skills/testdata/draft_week_valid.system.golden b/internal/ai/skills/testdata/draft_week_valid.system.golden new file mode 100644 index 0000000..12229c1 --- /dev/null +++ b/internal/ai/skills/testdata/draft_week_valid.system.golden @@ -0,0 +1,80 @@ +## Instructions +You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context. + +Rules: +- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD. +- Use only dinners from the candidate pool, each by its id exactly as the pool writes it. +- Plan each dinner at most once in the week. +- Avoid the dinners marked "planned last week" whenever the pool has enough others. +- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one. + +Give: +- days: the 7 days, each with date and dinner_id. +- note: one sentence of at most 160 characters about the week. + +One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household): +{"date": "2026-01-05", "dinner_id": "d1"} + +## Household context +### Today +- Thursday 2026-10-08 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite) + +### Quest log +- Pad Thai + +### Passed, oldest first +(none) + +### Recent plans +- 2026-10-05 Mon: Chicken Fajitas +- 2026-10-06 Tue: Fish Tacos + +### Candidate pool +- id "chili": Beef Chili (Regular, crockpot, 240 min) +- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week) +- id "bolognese": Spaghetti Bolognese (Regular, stovetop, 45 min) +- id "salmon": Sheet Pan Salmon (Regular, oven) +- id "tacos": Fish Tacos (Regular, 25 min, planned last week) +- id "padthai": Pad Thai (Quest log, stovetop, 35 min) +- id "roast": Pot Roast (Regular, crockpot) +- id "shawarma": Chicken Shawarma Bowls (Quest log, oven, 40 min) + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "draft_week" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "days": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "dinner_id": { + "type": "string" + } + }, + "required": [ + "date", + "dinner_id" + ], + "additionalProperties": false + } + }, + "note": { + "type": "string" + } + }, + "required": [ + "days", + "note" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/draft_week_valid.user.golden b/internal/ai/skills/testdata/draft_week_valid.user.golden new file mode 100644 index 0000000..9e14ded --- /dev/null +++ b/internal/ai/skills/testdata/draft_week_valid.user.golden @@ -0,0 +1 @@ +Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18. \ No newline at end of file