Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 c21d03dec6 Drop a followed batch failure once the deck reports it superseded
CI / ci (push) Successful in 3m3s
CI / ci (pull_request) Successful in 2m46s
After Discover showed failed batch j1, another session could finish a
newer batch. The deck then reported neither an active nor a failed batch,
as the server suppresses a failure a later batch supersedes, but the page
kept following j1 and its error stayed shown (or, before j1's first poll,
"Finding new side quests…" did, and the error arrived later).

Each new read of the deck is now reconciled: one reporting neither batch
stops following a job known to have failed, by its poll or by the deck's
previous read. A followed job that succeeded is still left followed, so
its end is read as before.

The replenish-failure test's stub now reports its failed batch on the
deck, as the server does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:41:06 -04:00
dev_agentandClaude Opus 5.5 b611d1bbce Report the latest failed suggestion batch on the deck
CI / ci (push) Successful in 3m3s
CI / ci (pull_request) Successful in 2m45s
The deck reported only a pending or running batch, so a Discover page
opened after a batch failed (or was interrupted by a restart) had no job
to follow and showed "All caught up" or the remaining cards with no
error. GET /api/v1/suggestions/deck now also returns failed_batch: the
newest batch when it failed, read in the same snapshot after orphaned
batches are failed, so at most one of active_batch and failed_batch is
set and a later batch supersedes the failure. The page follows the
failed batch's job like the active one, so its failure shows with the
existing Retry, which asks for a new batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:29:10 -04:00
dev_agent 6d36a7a10c Merge feature head a05d57c into task branch 2026-10-07 05:23:48 -04:00
orchestrator-agent a05d57cb2e Feature 13 integration fix: deck error surfacing, active-batch tracking, modal key scope, three-card deck (#111)
CI / ci (push) Successful in 2m43s
CI / ci (pull_request) Successful in 2m46s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 05:20:10 -04:00
dev_agentandClaude Opus 5.5 71060515bd Fail orphaned suggestion batches before reporting the deck's active batch
CI / ci (push) Successful in 3m5s
CI / ci (pull_request) Successful in 2m45s
Deck returned a pending or running batch's job id without checking that
the job was still queued or running, so after a restart failed the job
the page polled a dead job as the active batch. Deck and startBatch now
share activeBatch, which runs FailOrphanedSuggestionBatches before
reading the active batch; Deck therefore uses a write transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:13:48 -04:00
dev_agentandClaude Opus 5.5 af58caac94 Surface replenish failures, follow the active batch, scope deck keys, keep three deck depths
CI / ci (push) Successful in 3m2s
CI / ci (pull_request) Successful in 2m44s
Fixes the Feature 13 review findings on the Discover deck (Maestro task
4fc551f0-3c11-4c15-b638-f2c08a28eb29):

- F13-01: a batch failure (start, poll or job) is shown with its Retry
  whatever the deck has, so a replenish that fails while cards remain is
  no longer silent until the deck runs out.
- F13-02: GET /api/v1/suggestions/deck reports the batch pending or
  running (active_batch, read in the deck's snapshot), and a decision
  that calls for a replenish returns the job of the batch it started or
  joined. The page follows a newly reported active batch, so a reopened
  page or a second session shows skeleton cards while the batch runs
  instead of "All caught up".
- F13-03: ← / → leave the deck alone while it is inert behind a modal
  (the Quest log sheet or its confirmation).
- F13-04: a one- or two-card deck keeps all three depths: skeleton cards
  stand in for the missing ones while a batch finds more Side quests,
  blank stack-edge cards while none is coming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:04:20 -04:00
dev_agent 4d9d8afd8d Merge remote-tracking branch 'origin/dev' into feature/e3a8e4ea-8dcb-49e8-878f-552a6af109cb-discover-side-quest-suggestions
CI / ci (pull_request) Successful in 2m43s
CI / ci (push) Successful in 2m54s
2026-10-07 04:46:22 -04:00
orchestrator-agent 721a79bce9 Quest log sheet on Discover (#109)
CI / ci (push) Successful in 3m1s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 04:45:56 -04:00
orchestrator-agent a866dfad06 Optimistic Discover decisions and deck refill (#108)
CI / ci (push) Successful in 2m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 04:03:48 -04:00
orchestrator-agent 9088c313cc Swipe gesture on the Discover deck (#107)
CI / ci (push) Successful in 2m37s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 02:18:40 -04:00
orchestrator-agent c0ea1b9323 Try it and Pass animations (Discover deck) (#106)
CI / ci (push) Successful in 2m41s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 01:44:21 -04:00
orchestrator-agent db677d591f Discover deck screen (mock 1a) (#105)
CI / ci (push) Successful in 2m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 01:28:13 -04:00
orchestrator-agent 4c34467f8b Suggestion batches, deck and Try it / Pass API (#102)
CI / ci (push) Successful in 2m42s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 00:56:34 -04:00
orchestrator-agent 7d1e983fc8 suggest_side_quests skill v1 (#100)
CI / ci (push) Successful in 2m32s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-06 23:12:51 -04:00
6 changed files with 0 additions and 755 deletions
-328
View File
@@ -1,328 +0,0 @@
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
}
-324
View File
@@ -1,324 +0,0 @@
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)
}
})
}
}
-1
View File
@@ -4,4 +4,3 @@
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
@@ -1,21 +0,0 @@
{{/* 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}}
@@ -1,80 +0,0 @@
## 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
}
@@ -1 +0,0 @@
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.