Add the archive_title skill and title Supply runs on generation
CI / ci (push) Successful in 4m48s
CI / ci (pull_request) Successful in 4m53s

archive_title v1 writes a Supply run's post title for the Supply archive
from the week's dinner names in day order. Validation requires 1-48
characters on one line, no emoji and no surrounding quotation marks; a
reply breaking a rule gets the harness's one repair turn.

shopping.Generate and GeneratePushed run it, outside the generation's
transaction, whenever the stored list is still untitled, and store
shopping_lists.title only while the list is untitled and not frozen. A
title failure never fails the generation: the list stays untitled, the
failure is logged and recorded in ai_runs, and the week's next
generation tries again. A titled list keeps its title on remakes.

Maestro task fa4b94c9-6623-41ab-8d2a-0ca7d1e6401a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 05:31:59 -04:00
co-authored by Claude Opus 5.5
parent c4ea7a855e
commit fdfd0123fb
16 changed files with 822 additions and 21 deletions
+92 -15
View File
@@ -5,7 +5,8 @@
// 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 when the list is generated untitled (see Generate).
package shopping
import (
@@ -13,8 +14,10 @@ import (
"database/sql"
"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"
@@ -54,17 +57,30 @@ 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 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
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
// and logs to logger.
func NewService(st *store.Store, title Titler, 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 logger == nil:
return nil, errors.New("shopping: nil logger")
}
return &Service{st: st}, nil
return &Service{st: st, title: title, logger: logger}, nil
}
// List is a Supply run.
@@ -166,6 +182,13 @@ 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, is
// then titled by archive_title from the names of the plan's dinners in day
// order, outside the transaction. A title is written once: a remake keeps
// it. Titling never fails the generation: a title that cannot be written
// leaves the list untitled, as it is returned, the failure is logged, 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.
@@ -193,7 +216,11 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if _, err := household.WeekDays(weekStart); err != nil {
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
}
var out Generated
var (
out Generated
revision int64
names []string // the plan's dinners, in day order
)
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -206,10 +233,11 @@ 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
}
revision, names = plan.Revision, dinnerNames
now := store.Now().String()
list, err := q.GetShoppingList(ctx, weekStart.String())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@@ -240,9 +268,55 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
if err != nil {
return Generated{}, err
}
if out.List.Title == nil {
s.titleList(ctx, &out.List, revision, names)
}
return out, nil
}
// titleList titles l, an untitled Supply run made from dinners, the names
// of its plan's dinners in day order at plan revision, with archive_title,
// and stores the title on it. A title that cannot be written leaves l
// untitled; the failure is logged, never returned.
func (s *Service) titleList(ctx context.Context, l *List, revision int64, dinners []string) {
title, err := s.writeTitle(ctx, l, revision, dinners)
if err != nil {
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
"week", l.WeekStart, "list", l.ID, "error", err)
return
}
l.Title = &title
}
// writeTitle runs archive_title for l and stores its title. When l was
// titled by another generation meanwhile, that title stands and is
// returned.
func (s *Service) writeTitle(ctx context.Context, l *List, revision int64, dinners []string) (string, error) {
scope := fmt.Sprintf("%s@%d", l.WeekStart, revision)
out, err := s.title(ctx, scope, skills.ArchiveTitleRequest{Dinners: dinners})
if err != nil {
return "", fmt.Errorf("shopping: title: %w", err)
}
q := db.New(s.st.Writer)
n, err := q.SetShoppingListTitle(ctx, db.SetShoppingListTitleParams{
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: l.ID,
})
if err != nil {
return "", fmt.Errorf("shopping: store title: %w", err)
}
if n == 1 {
return out.Title, nil
}
list, err := q.GetShoppingList(ctx, l.WeekStart)
if err != nil {
return "", fmt.Errorf("shopping: store title: read list: %w", err)
}
if list.ID != l.ID || !list.Title.Valid {
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
}
return list.Title.String, nil
}
// 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 +439,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 +462,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 +478,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
+48 -2
View File
@@ -1,6 +1,8 @@
package shopping_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
@@ -8,10 +10,12 @@ import (
"log/slog"
"reflect"
"strings"
"sync"
"testing"
"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 +46,31 @@ type env struct {
planID string
// ids are the dinners' ids by name.
ids map[string]string
// 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()
// 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 +88,28 @@ 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 e.model, 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)
}
e.svc, err = shopping.NewService(st, title, slog.New(slog.NewTextHandler(e.log, nil)))
if err != nil {
t.Fatal(err)
}
return e
}
// ing is a recipe ingredient as recipe grounding stores it: an empty
+232
View File
@@ -0,0 +1,232 @@
package shopping_test
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"testing"
"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/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.
func titleWeek(t *testing.T) *env {
t.Helper()
e := newEnv(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
}
// TestGenerateTitlesNewList: a new list is titled by archive_title from
// the plan's dinners in day order, a dinner with no group included, and
// the title is stored and returned.
func TestGenerateTitlesNewList(t *testing.T) {
e := titleWeek(t)
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
g := e.generate(t)
wantTitle(t, "returned title", g.List.Title, "Noodles, Tacos and Chili")
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"))
g := e.generate(t)
wantTitle(t, "returned title", g.List.Title, "Noodles, Tacos and Chili")
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 and recorded in ai_runs.
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)
}
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)
wantUntitled(t, "first title", first.List.Title)
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)
}
wantTitle(t, "retried title", second.List.Title, "Noodles, Tacos and Chili")
wantTitle(t, "stored 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 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)
}
}
g := e.generate(t)
wantUntitled(t, "returned title", g.List.Title)
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())
}
}