diff --git a/cmd/server/main.go b/cmd/server/main.go index 8379e03..f1307ce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -133,7 +133,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - shopSvc, err := shopping.NewService(db) + shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger) if err != nil { return err } @@ -280,7 +280,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds // service registers its own kinds when it is built, before the runner // starts (dinners.NewService registers dinners.KindGroundDinner and // suggest.NewService suggest.KindSuggestionBatch, plan.NewService -// plan.KindDraftWeek). +// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) if err != nil { @@ -325,6 +325,18 @@ func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter { } } +// archiveTitler runs archive_title with the AI settings current at each +// run, sharing guard. +func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler { + return func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.ArchiveTitle{}, err + } + return skills.TitleArchive(ctx, cfg, scope, in) + } +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 91af226..c3c3dac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -470,6 +470,26 @@ automatic** (D-021). not a Regular; and one whose reason is over 180 characters or names none of its cited Regulars. Cited names are rewritten as the household writes them. Grounding each survivor in a recipe is the caller's next step. +- **Supply run titles** (`skills.TitleArchive`, skill `archive_title` + v1): from the names of the week's planned dinners in day order, writes + `{title}`, the Supply run's post title in the Supply archive, at the + household temperature with no household context. A title must be 1–48 + characters on one line, with no emoji and no surrounding quotation + marks; a reply breaking a rule gets the one repair turn, never a + truncation. `shopping.Generate` (and `GeneratePushed`), on storing a + list that is still untitled — a new list, or one a failed title left + untitled — enqueues a `title_supply_run` job in the generation's + transaction and returns without waiting for it, so neither Push to + Calendar nor `POST /shopping/{weekStart}/generate` waits for a model + call; the list reads untitled until the job stores its title. The job's + scope is `@/`, so runs share a title only for the same names (a renamed dinner + changes the names, not the revision). It writes `shopping_lists.title` + only while the list is untitled and not frozen. A title is written once; + a remake keeps it and enqueues no job. A failed title never fails the + generation: the list stays untitled (the UI shows the week label), the + failure is logged as a warning and recorded in `ai_runs`, the job fails + `title_failed`, and the week's next generation tries again. ## Recipe grounding diff --git a/internal/ai/skills/archive_title.go b/internal/ai/skills/archive_title.go new file mode 100644 index 0000000..611cbfd --- /dev/null +++ b/internal/ai/skills/archive_title.go @@ -0,0 +1,148 @@ +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// TitleLimit is the most characters a Supply run's title may have. +const TitleLimit = 48 + +// ArchiveTitleRequest is what archive_title reads. +type ArchiveTitleRequest struct { + // Dinners are the names of the week's planned dinners in day order, + // Monday first. It must hold at least one, none blank. + Dinners []string +} + +// ArchiveTitle is archive_title's output. +type ArchiveTitle struct { + // Title is the Supply run's post title in the Supply archive: 1 to + // TitleLimit characters on one line, with no emoji and no surrounding + // quotation marks. + Title string `json:"title"` +} + +// TitleArchive runs archive_title v1 on in and returns the title of the +// week's Supply run, written from its dinners. A request with no dinners, +// or a blank one, fails before any LLM call. scope names the week and its +// dinners (see harness.Run). +func TitleArchive(ctx context.Context, cfg harness.Config, scope string, in ArchiveTitleRequest) (ArchiveTitle, error) { + return harness.Run(ctx, cfg, archiveTitle{}, scope, in) +} + +// archiveTitle is the skill. +type archiveTitle struct{} + +var _ harness.Skill[ArchiveTitleRequest, ArchiveTitle] = archiveTitle{} + +func (archiveTitle) Name() string { return "archive_title" } + +func (archiveTitle) Version() int { return 1 } + +func (archiveTitle) Timeout() time.Duration { return harness.DefaultTimeout } + +// archiveTitlePrompt is the template data of archive_title.v1.tmpl. +type archiveTitlePrompt struct { + // Dinners are the week's dinner names in day order, each on one line. + Dinners []string +} + +func (archiveTitle) PromptInput(in ArchiveTitleRequest) (harness.PromptInput, error) { + if len(in.Dinners) == 0 { + return harness.PromptInput{}, errors.New("the week has no dinners to title it from") + } + var p archiveTitlePrompt + for i, name := range in.Dinners { + name = oneLine(name) + if name == "" { + return harness.PromptInput{}, fmt.Errorf("dinner %d has a blank name", i+1) + } + p.Dinners = append(p.Dinners, name) + } + return harness.PromptInput{Data: p}, nil +} + +func (archiveTitle) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "archive_title", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "required": ["title"], + "additionalProperties": false + }`)} +} + +// UnmarshalJSON decodes the output, requiring its field as the schema +// does. +func (t *ArchiveTitle) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "title"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain ArchiveTitle + return json.Unmarshal(data, (*plain)(t)) +} + +// quotes are the quotation marks a title may not start or end with. +const quotes = `"'` + "`" + "“”‘’„‚«»‹›「」『』" + +// Validate finds every broken rule of the title as PostProcess will store +// it, on one line: empty, over TitleLimit characters, holding an emoji, or +// starting or ending with a quotation mark. +func (archiveTitle) Validate(out ArchiveTitle) []string { + var problems []string + title := oneLine(out.Title) + switch n := utf8.RuneCountInString(title); { + case n == 0: + problems = append(problems, fmt.Sprintf("title is empty; give a title of 1 to %d characters", TitleLimit)) + case n > TitleLimit: + problems = append(problems, fmt.Sprintf("title %q has %d characters; shorten it to at most %d", title, n, TitleLimit)) + } + for _, r := range title { + if isEmoji(r) { + problems = append(problems, fmt.Sprintf("title %q has an emoji (%U); use words only", title, r)) + break + } + } + if first, _ := utf8.DecodeRuneInString(title); title != "" && strings.ContainsRune(quotes, first) { + problems = append(problems, fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", title)) + } else if last, _ := utf8.DecodeLastRuneInString(title); title != "" && strings.ContainsRune(quotes, last) { + problems = append(problems, fmt.Sprintf("title %q ends with a quotation mark; give the title without quotation marks around it", title)) + } + return problems +} + +// isEmoji reports whether r is an emoji or a part of one: a pictograph, +// a symbol from the emoji blocks, a regional indicator, a keycap, a skin +// tone, an emoji variation selector or the zero-width joiner. +func isEmoji(r rune) bool { + switch { + case r >= 0x1F000 && r <= 0x1FAFF: // tiles, cards, enclosed, pictographs, emoticons, transport, supplemental symbols + return true + case r >= 0x2600 && r <= 0x27BF: // miscellaneous symbols and dingbats + return true + case r >= 0x2B00 && r <= 0x2BFF: // arrows and stars such as ⭐ and ⬛ + return true + case r >= 0x2300 && r <= 0x23FF: // technical symbols such as ⌚ and ⏰ + return true + case r >= 0xE0020 && r <= 0xE007F: // tag sequences of flags + return true + case r == 0x200D, r == 0x20E3, r == 0xFE0F: // joiner, keycap, emoji presentation + return true + } + return false +} + +// PostProcess puts the title on one line, collapsing its white space. +func (archiveTitle) PostProcess(out ArchiveTitle) (ArchiveTitle, error) { + return ArchiveTitle{Title: oneLine(out.Title)}, nil +} diff --git a/internal/ai/skills/archive_title_test.go b/internal/ai/skills/archive_title_test.go new file mode 100644 index 0000000..52a320d --- /dev/null +++ b/internal/ai/skills/archive_title_test.go @@ -0,0 +1,170 @@ +package skills + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// titleWeek is a week of seven dinners, Monday first. +var titleWeek = ArchiveTitleRequest{Dinners: []string{ + "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Fish Tacos", +}} + +func titleReply(title string) llm.GenerateResult { + return reply(fmt.Sprintf(`{"title":%q}`, title)) +} + +// title runs TitleArchive on in with f and checks f was called exactly +// calls times, each at the household temperature with the archive_title +// schema. +func title(t *testing.T, f *llm.Fake, in ArchiveTitleRequest, calls int) (ArchiveTitle, []llm.Request, error) { + t.Helper() + out, err := TitleArchive(context.Background(), testConfig(t, f), "2026-10-12", 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 != "archive_title" { + t.Fatalf("request %d schema = %v, want archive_title", i+1, req.Schema) + } + } + return out, reqs, err +} + +func TestArchiveTitleValid(t *testing.T) { + var f llm.Fake + f.QueueGenerate(titleReply(" Slow Simmers\nand Taco Night ")) + out, reqs, err := title(t, &f, titleWeek, 1) + if err != nil { + t.Fatal(err) + } + if out.Title != "Slow Simmers and Taco Night" { + t.Fatalf("title = %q", out.Title) + } + system, user := reqs[0].System, reqs[0].Messages[0].Content + golden(t, "archive_title_valid.system.golden", system) + golden(t, "archive_title_valid.user.golden", user) + want := "Title the week whose dinners are, in day order from Monday:\n" + + "- Beef Chili\n- Pot Roast\n- Chicken Fajitas\n- Spaghetti Bolognese\n- Sheet Pan Salmon\n- Pad Thai\n- Fish Tacos" + if user != want { + t.Errorf("user message = %q, want %q", user, want) + } + if !strings.Contains(system, "## Output contract") || !strings.Contains(system, `"title"`) { + t.Error("system prompt lacks the output contract") + } +} + +// titleRepaired checks a first reply is repaired once, quoting it and the +// problem wanted. +func titleRepaired(t *testing.T, first, want string) { + t.Helper() + var f llm.Fake + f.QueueGenerate(titleReply(first), titleReply("Chili, Roast and Taco Night")) + out, reqs, err := title(t, &f, titleWeek, 2) + if err != nil { + t.Fatal(err) + } + if out.Title != "Chili, Roast and Taco Night" { + t.Fatalf("title = %q", out.Title) + } + repair := reqs[1].Messages + if len(repair) != 3 || repair[1].Content != fmt.Sprintf(`{"title":%q}`, first) { + t.Fatalf("repair turn does not quote the first reply: %+v", repair) + } + if !strings.Contains(repair[2].Content, want) { + t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want) + } +} + +func TestArchiveTitleTooLongRepaired(t *testing.T) { + long := "A Week of Slow Simmers, Sizzling Fajitas and Taco Night" // 55 characters + titleRepaired(t, long, fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", long)) +} + +func TestArchiveTitleEmojiRepaired(t *testing.T) { + titleRepaired(t, "Taco Night 🌮", `title "Taco Night 🌮" has an emoji (U+1F32E); use words only`) +} + +func TestArchiveTitleQuotedRepaired(t *testing.T) { + titleRepaired(t, `"Taco Night"`, `title "\"Taco Night\"" starts with a quotation mark; give the title without quotation marks around it`) +} + +func TestArchiveTitleEmojiTwiceFails(t *testing.T) { + var f llm.Fake + f.QueueGenerate(titleReply("Chili Week 🔥"), titleReply("Chili Week ❤️")) + out, _, err := title(t, &f, titleWeek, 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.Equal(bad.Repair, []string{`title "Chili Week ❤️" has an emoji (U+2764); use words only`}) { + t.Fatalf("repair problems = %q", bad.Repair) + } + if out.Title != "" { + t.Fatalf("title = %q, want none", out.Title) + } +} + +func TestArchiveTitleRules(t *testing.T) { + s := archiveTitle{} + for _, tc := range []struct { + title string + want []string + }{ + {"Taco Night", nil}, + {strings.Repeat("a", TitleLimit), nil}, + {"Crème Brûlée & Co.", nil}, + {"Fajitas — Then Tacos", nil}, + {"Mom's Pot Roast Week", nil}, + {" Spaced out ", nil}, + {"", []string{"title is empty; give a title of 1 to 48 characters"}}, + {" \n ", []string{"title is empty; give a title of 1 to 48 characters"}}, + {strings.Repeat("é", TitleLimit+1), []string{fmt.Sprintf("title %q has 49 characters; shorten it to at most 48", strings.Repeat("é", TitleLimit+1))}}, + {"Hot ☕ Week", []string{`title "Hot ☕ Week" has an emoji (U+2615); use words only`}}, + {"Star ⭐ Week", []string{`title "Star ⭐ Week" has an emoji (U+2B50); use words only`}}, + {"Go 🇮🇹", []string{`title "Go 🇮🇹" has an emoji (U+1F1EE); use words only`}}, + {"‘Taco Night’", []string{`title "‘Taco Night’" starts with a quotation mark; give the title without quotation marks around it`}}, + {"Taco Night”", []string{`title "Taco Night”" ends with a quotation mark; give the title without quotation marks around it`}}, + {"«Pasta» 🍝 " + strings.Repeat("x", 45), []string{ + fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", "«Pasta» 🍝 "+strings.Repeat("x", 45)), + fmt.Sprintf("title %q has an emoji (U+1F35D); use words only", "«Pasta» 🍝 "+strings.Repeat("x", 45)), + fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", "«Pasta» 🍝 "+strings.Repeat("x", 45)), + }}, + } { + if got := s.Validate(ArchiveTitle{Title: tc.title}); !slices.Equal(got, tc.want) { + t.Errorf("Validate(%q) =\n%q\nwant\n%q", tc.title, got, tc.want) + } + } +} + +func TestArchiveTitleBadRequestFailsBeforeCall(t *testing.T) { + for name, tc := range map[string]struct { + in ArchiveTitleRequest + want string + }{ + "no dinners": {ArchiveTitleRequest{}, "the week has no dinners to title it from"}, + "blank dinner": {ArchiveTitleRequest{Dinners: []string{"Pad Thai", " \n"}}, "dinner 2 has a blank name"}, + } { + t.Run(name, func(t *testing.T) { + var f llm.Fake + _, _, err := title(t, &f, tc.in, 0) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want one containing %q", err, tc.want) + } + }) + } +} diff --git a/internal/ai/skills/prompts/archive_title.v1.tmpl b/internal/ai/skills/prompts/archive_title.v1.tmpl new file mode 100644 index 0000000..ffa621e --- /dev/null +++ b/internal/ai/skills/prompts/archive_title.v1.tmpl @@ -0,0 +1,22 @@ +{{/* archive_title titles a week's Supply run for the Supply archive, from the week's dinners. */}} +{{define "instructions" -}} +You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners. + +Rules: +- At most 48 characters, on one line. +- Words only: no emoji. +- No quotation marks around the title. +- Do not name a date or week number; the archive shows those. + +Give: +- title: the title. + +One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household): +{"title": "Slow Simmers and Taco Night"} +{{- end}} +{{define "user" -}} +Title the week whose dinners are, in day order from Monday: +{{- range .Dinners}} +- {{.}} +{{- end}} +{{- end}} diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index 79ec5ec..56c2739 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -5,3 +5,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 +archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a diff --git a/internal/ai/skills/testdata/archive_title_valid.system.golden b/internal/ai/skills/testdata/archive_title_valid.system.golden new file mode 100644 index 0000000..00ba6e1 --- /dev/null +++ b/internal/ai/skills/testdata/archive_title_valid.system.golden @@ -0,0 +1,33 @@ +## Instructions +You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners. + +Rules: +- At most 48 characters, on one line. +- Words only: no emoji. +- No quotation marks around the title. +- Do not name a date or week number; the archive shows those. + +Give: +- title: the title. + +One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household): +{"title": "Slow Simmers and Taco Night"} + +## Household context +No household context. + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "archive_title" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "required": [ + "title" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/archive_title_valid.user.golden b/internal/ai/skills/testdata/archive_title_valid.user.golden new file mode 100644 index 0000000..be8140f --- /dev/null +++ b/internal/ai/skills/testdata/archive_title_valid.user.golden @@ -0,0 +1,8 @@ +Title the week whose dinners are, in day order from Monday: +- Beef Chili +- Pot Roast +- Chicken Fajitas +- Spaghetti Bolognese +- Sheet Pan Salmon +- Pad Thai +- Fish Tacos \ No newline at end of file diff --git a/internal/db/querier.go b/internal/db/querier.go index f4dd2e5..3e143c4 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -243,6 +243,9 @@ type Querier interface { SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) // Marks item id needed (1) or "have it" (0). SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error + // Titles list id, unless it has a title already or is frozen into the + // Supply archive. + SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) // Marks a pending batch running; a batch in any other status is left as // it is (0 rows). StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index 3044019..8652edd 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -378,3 +378,25 @@ func (q *Queries) SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItem _, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID) return err } + +const setShoppingListTitle = `-- name: SetShoppingListTitle :execrows +UPDATE shopping_lists +SET title = ?, updated_at = ? +WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL +` + +type SetShoppingListTitleParams struct { + Title sql.NullString + UpdatedAt string + ID string +} + +// Titles list id, unless it has a title already or is frozen into the +// Supply archive. +func (q *Queries) SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setShoppingListTitle, arg.Title, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index cf9baae..fd6d3d7 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -37,6 +37,7 @@ type plansEnv struct { h http.Handler st *store.Store svc *plan.Service + runner *jobs.Runner questLog string // google is the fake Google the plan service pushes to; it starts // unconnected. @@ -47,6 +48,13 @@ type plansEnv struct { } func newPlansRouter(t *testing.T) plansEnv { + t.Helper() + return newPlansRouterTitled(t, untitled) +} + +// newPlansRouterTitled is newPlansRouter whose Supply runs are titled by +// title. +func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Helper() st := newTestStore(t) ctx := t.Context() @@ -80,7 +88,7 @@ func newPlansRouter(t *testing.T) plansEnv { if err != nil { t.Fatal(err) } - shop, err := shopping.NewService(st) + shop, err := shopping.NewService(st, title, runner, logger) if err != nil { t.Fatal(err) } @@ -119,7 +127,7 @@ func newPlansRouter(t *testing.T) plansEnv { t.Fatal(err) } } - return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), + return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), google: google, gcal: googleSvc, logs: logs} } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3a57cde..c735e54 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -211,16 +211,28 @@ func newTestPlan(t *testing.T) *plan.Service { } // newTestShopping returns a shopping service over a migrated temp -// database. +// database, whose Supply runs stay untitled. func newTestShopping(t *testing.T) *shopping.Service { t.Helper() - svc, err := shopping.NewService(newTestStore(t)) + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + svc, err := shopping.NewService(st, untitled, runner, logger) if err != nil { t.Fatal(err) } return svc } +// untitled is a shopping.Titler with no model: every Supply run it is +// asked to title stays untitled. +func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + return skills.ArchiveTitle{}, errors.New("no model in this test") +} + // newTestBox returns a secret box under a random key. func newTestBox(t *testing.T) *secretbox.Box { t.Helper() diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go index 9ef7d42..350b100 100644 --- a/internal/server/shopping_test.go +++ b/internal/server/shopping_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "database/sql" "encoding/json" "net/http" @@ -8,7 +9,9 @@ import ( "slices" "strings" "testing" + "time" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -378,6 +381,128 @@ func TestPushPlanMakesSupplyRun(t *testing.T) { } } +// heldTitler is a shopping.Titler that reports each call on entered and +// answers title only once release is closed. +type heldTitler struct { + entered chan struct{} + release chan struct{} +} + +func newHeldTitler() heldTitler { + return heldTitler{entered: make(chan struct{}, 4), release: make(chan struct{})} +} + +func (h heldTitler) title(ctx context.Context, _ string, _ skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + h.entered <- struct{}{} + select { + case <-h.release: + return skills.ArchiveTitle{Title: "Chili and Friends"}, nil + case <-ctx.Done(): + return skills.ArchiveTitle{}, ctx.Err() + } +} + +// startRunner starts e's job runner, stopping it when the test ends. e's +// logs must not be read while it runs. +func (e plansEnv) startRunner(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := e.runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + e.runner.Wait() + }) +} + +// waitEntered waits for the title call to begin. +func (h heldTitler) waitEntered(t *testing.T) { + t.Helper() + select { + case <-h.entered: + case <-time.After(10 * time.Second): + t.Fatal("archive_title was never called") + } +} + +// waitTitled waits for the week's Supply run to have a title through GET +// and returns it. +func (e plansEnv) waitTitled(t *testing.T) string { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + var l supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if l.Title != nil { + return *l.Title + } + if time.Now().After(deadline) { + t.Fatal("the Supply run was never titled") + } + time.Sleep(5 * time.Millisecond) + } +} + +// TestPushPlanDoesNotWaitForTitle: Push to Calendar answers while the +// Supply run's title is still being written; the list reads untitled until +// the title job stores it. +func TestPushPlanDoesNotWaitForTitle(t *testing.T) { + t.Parallel() + held := newHeldTitler() + e := newPlansRouterTitled(t, held.title) + e.groundWeek(t) + e.connectCalendar(t, true) + e.startRunner(t) + + rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/push-calendar", "") + if rec.Code != http.StatusOK || body["status"] != "synced" { + t.Fatalf("push = %d %s", rec.Code, rec.Body) + } + if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) { + t.Errorf("supply_run = %v, want %v", body["supply_run"], want) + } + held.waitEntered(t) + var l supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if l.Title != nil { + t.Errorf("title = %q while archive_title is still running, want null", *l.Title) + } + + close(held.release) + if got := e.waitTitled(t); got != "Chili and Friends" { + t.Errorf("title = %q, want the title job's", got) + } +} + +// TestSupplyRunGenerateDoesNotWaitForTitle: POST generate answers with the +// list untitled while its title is still being written. +func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) { + t.Parallel() + held := newHeldTitler() + e := newPlansRouterTitled(t, held.title) + e.groundWeek(t) + e.startRunner(t) + + var l supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK { + t.Fatalf("generate = %d %v", status, body) + } + if l.Title != nil { + t.Errorf("title = %q while archive_title is still running, want null", *l.Title) + } + if !reflect.DeepEqual(l.render(), weekLines) { + t.Errorf("generated = %v, want %v", l.render(), weekLines) + } + held.waitEntered(t) + + close(held.release) + if got := e.waitTitled(t); got != "Chili and Friends" { + t.Errorf("title = %q, want the title job's", got) + } +} + // TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply // run. func TestFailedPushMakesNoSupplyRun(t *testing.T) { diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 1530b96..1ed9097 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -5,19 +5,26 @@ // Generate makes a week's Supply run from its plan, and remakes it after // the plan changes. It is deterministic and runs no model: an item is one // ingredient of the dinner's recipe exactly as recipe grounding stored it -// (see Generate). +// (see Generate). The one model call is the Supply run's title, written by +// archive_title in a KindTitleSupplyRun job when the list is generated +// untitled (see Generate). package shopping import ( "context" + "crypto/sha256" "database/sql" + "encoding/json" "errors" "fmt" + "log/slog" "strings" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -36,6 +43,15 @@ const ( SkipNoIngredients = "no_ingredients" ) +// KindTitleSupplyRun is the job kind that titles a Supply run with +// archive_title. Only Generate and GeneratePushed enqueue it. +const KindTitleSupplyRun = "title_supply_run" + +// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that +// stored no title: archive_title failed, or the Supply run was frozen or +// replaced first. The Supply run stays untitled. +const ReasonTitleFailed = "title_failed" + // ErrPlanNotFound is a generation for a week with no plan. var ErrPlanNotFound = errors.New("the week has no plan") @@ -54,17 +70,44 @@ var ErrListNotFound = errors.New("the week has no Supply run") // ErrItemNotFound is a change to an item on no Supply run. var ErrItemNotFound = errors.New("no Supply run has that item") +// Titler runs archive_title on in; scope names the week, its plan revision +// and its dinners. +// In production it is skills.TitleArchive with the household's +// harness.Config. +type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) + // Service generates Supply runs. type Service struct { - st *store.Store + st *store.Store + title Titler + titleJob *jobs.Kind[TitleJob] + logger *slog.Logger } -// NewService returns a service over st. -func NewService(st *store.Store) (*Service, error) { - if st == nil { +// NewService returns a service over st that titles Supply runs with title +// in KindTitleSupplyRun jobs, which it registers on runner, and logs to +// logger. runner must not have started yet. +func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { + switch { + case st == nil: return nil, errors.New("shopping: nil store") + case title == nil: + return nil, errors.New("shopping: nil titler") + case runner == nil: + return nil, errors.New("shopping: nil job runner") + case logger == nil: + return nil, errors.New("shopping: nil logger") } - return &Service{st: st}, nil + s := &Service{st: st, title: title, logger: logger} + kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed) + if err != nil { + return nil, err + } + if err := kind.ServiceOnly(); err != nil { + return nil, err + } + s.titleJob = kind + return s, nil } // List is a Supply run. @@ -142,6 +185,9 @@ type Generated struct { // Skipped are the plan days whose dinner has no ingredients to shop // for, by date. Skipped []SkippedDay `json:"skipped"` + // TitleJobID is the KindTitleSupplyRun job titling the list; nil when + // the list already has its title. + TitleJobID *string `json:"title_job_id"` } // Generate makes the Supply run of the week starting weekStart from the @@ -166,6 +212,16 @@ type Generated struct { // new and needed; an item no longer on the plan is removed, and with it a // group no longer on the plan. // +// A list still untitled once it is stored, as a new list always is, gets a +// KindTitleSupplyRun job, enqueued in the same transaction, that titles it +// with archive_title from the names of the plan's dinners in day order. +// Generate returns without waiting for the job: the list is returned +// untitled, with the job's id. A title is written once: a remake keeps it +// and enqueues no job. Titling never fails the generation: a title that +// cannot be written leaves the list untitled, the failure is logged and +// the job failed with ReasonTitleFailed, and the next generation of the +// week, Generate or GeneratePushed, tries again. +// // A week with no plan is ErrPlanNotFound; a list frozen into the Supply // archive is ErrFrozen. A fetched recipe's ingredient without a name is an // error: grounding always names one. @@ -206,7 +262,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check if err := check(plan); err != nil { return err } - want, skipped, err := wanted(ctx, q, plan.ID) + want, skipped, dinnerNames, err := wanted(ctx, q, plan.ID) if err != nil { return err } @@ -235,14 +291,117 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check return err } out = Generated{List: listOf(list, groups), Skipped: skipped} + if list.Title.Valid { + return nil + } + id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{ + ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames, + }) + if err != nil { + return fmt.Errorf("shopping: enqueue title: %w", err) + } + out.TitleJobID = &id return nil }) if err != nil { return Generated{}, err } + if out.TitleJobID != nil { + s.titleJob.Wake() + } return out, nil } +// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run +// ListID of the week WeekStart, made from the plan at Revision, whose +// dinners in day order are Dinners. +type TitleJob struct { + ListID string `json:"list_id"` + WeekStart string `json:"week_start"` + Revision int64 `json:"revision"` + Dinners []string `json:"dinners"` +} + +// titleResult is a KindTitleSupplyRun job's result: the Supply run and the +// title it has. +type titleResult struct { + ListID string `json:"list_id"` + Title string `json:"title"` +} + +// PublicJSON returns the Supply run's id and title. +func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } + +// runTitleJob titles the Supply run of p with archive_title and stores the +// title. A title that cannot be stored leaves the list untitled: the +// failure is logged and the job fails with ReasonTitleFailed. +func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) { + title, err := s.writeTitle(ctx, p) + if err != nil { + s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again", + "week", p.WeekStart, "list", p.ListID, "error", err) + return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed} + } + return titleResult{ListID: p.ListID, Title: title}, nil +} + +// writeTitle runs archive_title for p's Supply run and stores its title. +// When the list was titled by another job, before this one ran or +// meanwhile, that title stands and is returned. +func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) { + q := db.New(s.st.Writer) + title, err := s.currentTitle(ctx, q, p) + if err != nil || title != "" { + return title, err + } + out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners}) + if err != nil { + return "", fmt.Errorf("shopping: title: %w", err) + } + write, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{ + Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID, + }) + if err != nil { + return "", fmt.Errorf("shopping: store title: %w", err) + } + if n == 1 { + return out.Title, nil + } + title, err = s.currentTitle(write, q, p) + if err == nil && title == "" { + err = errors.New("shopping: store title: the Supply run is still untitled") + } + return title, err +} + +// currentTitle returns the title of p's Supply run, or "" while it is +// untitled. A list frozen or replaced untitled is an error: it can no +// longer be titled. +func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) { + list, err := q.GetShoppingList(ctx, p.WeekStart) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("shopping: title: read list: %w", err) + } + switch { + case err == nil && list.ID == p.ListID && list.Title.Valid: + return list.Title.String, nil + case err != nil || list.ID != p.ListID || frozen(list): + return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored") + } + return "", nil +} + +// titleScope is the archive_title scope of p: its week and plan revision, +// and a fingerprint of its dinners in day order, so runs share a title only +// when they title the same dinner names. A renamed dinner changes the +// names but not the plan revision. +func titleScope(p TitleJob) string { + names, _ := json.Marshal(p.Dinners) // a []string always encodes + return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names)) +} + // Get returns the Supply run of the week starting weekStart, as stored: a // week with none is ErrListNotFound. func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) { @@ -365,18 +524,21 @@ type wantItem struct { } // wanted reads plan planID's days and their dinners' ingredients: the -// groups the list must have, by date, and the days that get none. -func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, error) { +// groups the list must have, by date, the days that get none, and the +// names of the plan's dinners in day order. +func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, []string, error) { days, err := q.ListPlanDays(ctx, planID) if err != nil { - return nil, nil, fmt.Errorf("shopping: read plan days: %w", err) + return nil, nil, nil, fmt.Errorf("shopping: read plan days: %w", err) } var ( groups []wantGroup skipped []SkippedDay + names []string ) for _, day := range days { d := day.Dinner + names = append(names, d.Name) skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name} if !d.RecipeID.Valid { skip.Reason = SkipNoRecipe @@ -385,11 +547,11 @@ func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []S } recipe, err := q.GetRecipe(ctx, d.RecipeID.String) if err != nil { - return nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err) + return nil, nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err) } ings, err := q.ListRecipeIngredients(ctx, recipe.ID) if err != nil { - return nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err) + return nil, nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err) } if len(ings) == 0 { skip.Reason = SkipNoIngredients @@ -401,13 +563,13 @@ func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []S for _, ing := range ings { it, err := item(ing, manual) if err != nil { - return nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err) + return nil, nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err) } g.items = append(g.items, it) } groups = append(groups, g) } - return groups, skipped, nil + return groups, skipped, names, nil } // item is the item for ingredient ing of a recipe entered by hand diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index 733ff53..a746343 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -1,6 +1,8 @@ package shopping_test import ( + "bytes" + "context" "database/sql" "errors" "fmt" @@ -8,10 +10,13 @@ import ( "log/slog" "reflect" "strings" + "sync" "testing" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -42,6 +47,35 @@ type env struct { planID string // ids are the dinners' ids by name. ids map[string]string + // runner runs the title jobs, two at a time, once start starts it. + runner *jobs.Runner + // model answers archive_title, which titles the Supply runs; with + // nothing queued it fails, and the lists stay untitled. + model *llm.Fake + // beforeTitle, when set, runs as archive_title is asked for a title. + beforeTitle func() + // beforeModel, when set, runs as the model is sent a request. + beforeModel func(llm.Request) + // log holds what the service logged. + log *syncBuffer +} + +// syncBuffer is a bytes.Buffer safe for concurrent use. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() } func newEnv(t *testing.T) *env { @@ -59,11 +93,85 @@ func newEnv(t *testing.T) *env { if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil { t.Fatal(err) } - svc, err := shopping.NewService(st) + e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}} + guard, err := harness.NewGuard(harness.DefaultMaxCalls) if err != nil { t.Fatal(err) } - return &env{svc: svc, st: st, dataDir: dataDir, ids: map[string]string{}} + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return hookedModel{e}, nil }, + Store: st, + Guard: guard, + } + title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + if e.beforeTitle != nil { + e.beforeTitle() + } + return skills.TitleArchive(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(e.log, nil)) + e.runner, err = jobs.NewRunner(st, logger, 2) + if err != nil { + t.Fatal(err) + } + e.svc, err = shopping.NewService(st, title, e.runner, logger) + if err != nil { + t.Fatal(err) + } + return e +} + +// hookedModel is the env's model, running beforeModel first. +type hookedModel struct{ e *env } + +func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) { + if m.e.beforeModel != nil { + m.e.beforeModel(req) + } + return m.e.model.Generate(ctx, req) +} + +func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) { + return m.e.model.ListModels(ctx) +} + +func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) } + +// start starts the env's job runner, stopping it when the test ends. +func (e *env) start(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := e.runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + e.runner.Wait() + }) +} + +// waitTitle waits for g's title job to finish and returns it. +func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job { + t.Helper() + if g.TitleJobID == nil { + t.Fatal("generation enqueued no title job") + } + deadline := time.Now().Add(10 * time.Second) + for { + job, err := e.runner.Get(t.Context(), *g.TitleJobID) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { + return job + } + if time.Now().After(deadline) { + t.Fatalf("title job %s still %s", job.ID, job.Status) + } + time.Sleep(5 * time.Millisecond) + } } // ing is a recipe ingredient as recipe grounding stores it: an empty @@ -349,8 +457,13 @@ func TestGenerateSevenDayPlan(t *testing.T) { } // Generating again from the same plan changes nothing: same rows, same - // ids, no extra rows. + // ids, no extra rows. The list is still untitled, so it gets another + // title job. again := e.generate(t) + if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID { + t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID)) + } + again.TitleJobID = g.TitleJobID if !reflect.DeepEqual(again, g) { t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g) } diff --git a/internal/shopping/title_test.go b/internal/shopping/title_test.go new file mode 100644 index 0000000..00cf399 --- /dev/null +++ b/internal/shopping/title_test.go @@ -0,0 +1,313 @@ +package shopping_test + +import ( + "database/sql" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" +) + +// titled is archive_title's reply giving title. +func titled(title string) llm.GenerateResult { + js := fmt.Sprintf(`{"title":%q}`, title) + return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}} +} + +// titleWeek stores three dinners, one with no recipe, and plans them on +// the week's first three days; its job runner is started. +func titleWeek(t *testing.T) *env { + t.Helper() + e := newEnv(t) + e.start(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.dinner(t, "Fish Tacos") + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) + e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili") + return e +} + +// storedTitle returns the week's list's stored title; nil when NULL. +func (e *env) storedTitle(t *testing.T) *string { + t.Helper() + var title sql.NullString + if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil { + t.Fatal(err) + } + if !title.Valid { + return nil + } + return &title.String +} + +// runStatuses returns the status of every archive_title run recorded in +// ai_runs, oldest first. +func (e *env) runStatuses(t *testing.T) []string { + t.Helper() + rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatal(err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func wantTitle(t *testing.T, what string, got *string, want string) { + t.Helper() + if got == nil || *got != want { + t.Errorf("%s = %v, want %q", what, deref(got), want) + } +} + +func wantUntitled(t *testing.T, what string, got *string) { + t.Helper() + if got != nil { + t.Errorf("%s = %q, want none", what, *got) + } +} + +func deref(s *string) any { + if s == nil { + return nil + } + return *s +} + +// wantJob fails t unless job ended with status and, when failed, code. +func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) { + t.Helper() + if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) { + t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code) + } +} + +// TestGenerateTitlesNewList: a new list is returned untitled with a title +// job, which titles it by archive_title from the plan's dinners in day +// order, a dinner with no group included, and stores the title. +func TestGenerateTitlesNewList(t *testing.T) { + e := titleWeek(t) + e.model.QueueGenerate(titled("Noodles, Tacos and Chili")) + + g := e.generate(t) + wantUntitled(t, "returned title", g.List.Title) + job := e.waitTitle(t, g) + + wantJob(t, job, jobs.StatusSucceeded, "") + if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want { + t.Errorf("job result = %s, want %s", job.Result, want) + } + wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili") + got, err := e.svc.Get(t.Context(), week) + if err != nil { + t.Fatal(err) + } + wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili") + reqs := e.model.Requests() + if len(reqs) != 1 { + t.Fatalf("%d model calls, want 1", len(reqs)) + } + want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili" + if user := reqs[0].Messages[0].Content; user != want { + t.Errorf("user message = %q, want %q", user, want) + } + if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) { + t.Errorf("archive_title runs = %q, want one ok", s) + } +} + +// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired +// through the repair turn, and the repaired title is stored, not a +// truncation of the first. +func TestGenerateTitleTooLongRepaired(t *testing.T) { + e := titleWeek(t) + long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday" + e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili")) + + wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "") + + wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili") + reqs := e.model.Requests() + if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") { + t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs) + } + if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) { + t.Errorf("archive_title runs = %q, want one repaired", s) + } +} + +// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice +// fails, and the list is still made, whole and untitled, with the failure +// logged, recorded in ai_runs and failing the title job. +func TestGenerateTitleFailureKeepsList(t *testing.T) { + e := titleWeek(t) + e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮")) + + g, err := e.svc.Generate(t.Context(), week) + if err != nil { + t.Fatalf("Generate = %v, want the list made though its title failed", err) + } + + want := []string{ + "0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed", + "1 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed", + } + wantLines(t, "list", lines(g.List), want) + wantLines(t, "stored list", e.stored(t), want) + if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" { + t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped) + } + wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed) + wantUntitled(t, "returned title", g.List.Title) + wantUntitled(t, "stored title", e.storedTitle(t)) + if g.List.Status != shopping.StatusCurrent { + t.Errorf("status = %q, want current", g.List.Status) + } + log := e.log.String() + for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} { + if !strings.Contains(log, part) { + t.Errorf("log lacks %q:\n%s", part, log) + } + } + if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) { + t.Errorf("archive_title runs = %q, want one bad_output", s) + } +} + +// TestGenerateRetriesTitle: a list left untitled is titled by the week's +// next generation, Generate or GeneratePushed; once titled, a remake keeps +// its title and asks for none. +func TestGenerateRetriesTitle(t *testing.T) { + e := titleWeek(t) + e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)}) + first := e.generate(t) + wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed) + wantUntitled(t, "first title", e.storedTitle(t)) + if !strings.Contains(e.log.String(), "model down") { + t.Errorf("log lacks the failure:\n%s", e.log.String()) + } + + e.model.QueueGenerate(titled("Noodles, Tacos and Chili")) + plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String()) + if err != nil { + t.Fatal(err) + } + second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision) + if err != nil { + t.Fatal(err) + } + if second.List.ID != first.List.ID { + t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID) + } + wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "") + wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili") + + e.swap(t, "2026-10-12", "Beef Chili") + third := e.generate(t) + wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili") + if third.TitleJobID != nil { + t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID) + } + if n := len(e.model.Requests()); n != 2 { + t.Errorf("%d model calls, want 2: a titled list is not titled again", n) + } +} + +// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply +// archive while its title is written keeps no title, and the failure is +// logged. +func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) { + e := titleWeek(t) + e.model.QueueGenerate(titled("Noodles, Tacos and Chili")) + e.beforeTitle = func() { + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + t.Error(err) + } + } + + wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed) + + wantUntitled(t, "stored title", e.storedTitle(t)) + if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") { + t.Errorf("log lacks the failure:\n%s", e.log.String()) + } +} + +// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two +// generations of an untitled list changes the names to title but not the +// plan revision. The two title jobs, running at once, each send the model +// their own names; neither is given the other's title. +func TestGenerateTitleOverlapAcrossRename(t *testing.T) { + e := titleWeek(t) + e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week")) + // Each model call waits for the other, so the runs overlap. + var arrived sync.WaitGroup + arrived.Add(2) + e.beforeModel = func(llm.Request) { + arrived.Done() + done := make(chan struct{}) + go func() { arrived.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("the title runs did not overlap: one model call never came") + } + } + revision := func() int64 { + p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String()) + if err != nil { + t.Fatal(err) + } + return p.Revision + } + + before := revision() + first := e.generate(t) + if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil { + t.Fatal(err) + } + second := e.generate(t) + if revision() != before { + t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision()) + } + + wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "") + wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "") + var users []string + for _, r := range e.model.Requests() { + users = append(users, r.Messages[0].Content) + } + slices.Sort(users) + want := []string{ + "Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili", + "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili", + } + if !slices.Equal(users, want) { + t.Errorf("model was sent %q, want each generation's own names %q", users, want) + } + if s := e.runStatuses(t); len(s) != 2 { + t.Errorf("archive_title runs = %q, want two", s) + } + if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") { + t.Errorf("stored title = %v, want one of the two titles", deref(got)) + } +} diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index 59f8451..76782fe 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -81,3 +81,10 @@ WHERE shopping_items.id = ?; UPDATE shopping_items SET needed = ?, updated_at = ? WHERE id = ?; + +-- name: SetShoppingListTitle :execrows +-- Titles list id, unless it has a title already or is frozen into the +-- Supply archive. +UPDATE shopping_lists +SET title = ?, updated_at = ? +WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;