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