Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9baf0862b |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@
|
||||
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
||||
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user