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
+13 -1
View File
@@ -133,7 +133,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err return err
} }
shopSvc, err := shopping.NewService(db) shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), logger)
if err != nil { if err != nil {
return err return err
} }
@@ -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 // logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log. // so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) { func logListening(logger *slog.Logger, cfg config.Config) {
+14
View File
@@ -470,6 +470,20 @@ automatic** (D-021).
not a Regular; and one whose reason is over 180 characters or names none 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 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. 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`) runs it after
storing a list that is still untitled — a new list, or one a failed
title left untitled — outside the generation's transaction, and writes
`shopping_lists.title` only while the list is untitled and not frozen.
A title is written once; a remake keeps it. 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`, and the
week's next generation tries again.
## Recipe grounding ## Recipe grounding
+148
View File
@@ -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
}
+170
View File
@@ -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)
}
})
}
}
@@ -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}}
+1
View File
@@ -5,3 +5,4 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
@@ -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
}
@@ -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
+3
View File
@@ -243,6 +243,9 @@ type Querier interface {
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
// Marks item id needed (1) or "have it" (0). // Marks item id needed (1) or "have it" (0).
SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error 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 // Marks a pending batch running; a batch in any other status is left as
// it is (0 rows). // it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
+22
View File
@@ -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) _, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID)
return err 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()
}
+1 -1
View File
@@ -80,7 +80,7 @@ func newPlansRouter(t *testing.T) plansEnv {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
shop, err := shopping.NewService(st) shop, err := shopping.NewService(st, untitled, logger)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+8 -2
View File
@@ -211,16 +211,22 @@ func newTestPlan(t *testing.T) *plan.Service {
} }
// newTestShopping returns a shopping service over a migrated temp // newTestShopping returns a shopping service over a migrated temp
// database. // database, whose Supply runs stay untitled.
func newTestShopping(t *testing.T) *shopping.Service { func newTestShopping(t *testing.T) *shopping.Service {
t.Helper() t.Helper()
svc, err := shopping.NewService(newTestStore(t)) svc, err := shopping.NewService(newTestStore(t), untitled, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
return svc 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. // newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box { func newTestBox(t *testing.T) *secretbox.Box {
t.Helper() t.Helper()
+92 -15
View File
@@ -5,7 +5,8 @@
// Generate makes a week's Supply run from its plan, and remakes it after // 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 // 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 // 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 package shopping
import ( import (
@@ -13,8 +14,10 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"strings" "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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "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/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. // ErrItemNotFound is a change to an item on no Supply run.
var ErrItemNotFound = errors.New("no Supply run has that item") 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. // Service generates Supply runs.
type Service struct { type Service struct {
st *store.Store st *store.Store
title Titler
logger *slog.Logger
} }
// NewService returns a service over st. // NewService returns a service over st that titles Supply runs with title
func NewService(st *store.Store) (*Service, error) { // and logs to logger.
if st == nil { func NewService(st *store.Store, title Titler, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("shopping: nil store") 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. // 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 // new and needed; an item no longer on the plan is removed, and with it a
// group no longer on the plan. // 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 // 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 // archive is ErrFrozen. A fetched recipe's ingredient without a name is an
// error: grounding always names one. // 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 { if _, err := household.WeekDays(weekStart); err != nil {
return Generated{}, fmt.Errorf("shopping: generate: %w", err) 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 { err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) q := db.New(tx)
plan, err := q.GetWeekPlan(ctx, weekStart.String()) 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 { if err := check(plan); err != nil {
return err return err
} }
want, skipped, err := wanted(ctx, q, plan.ID) want, skipped, dinnerNames, err := wanted(ctx, q, plan.ID)
if err != nil { if err != nil {
return err return err
} }
revision, names = plan.Revision, dinnerNames
now := store.Now().String() now := store.Now().String()
list, err := q.GetShoppingList(ctx, weekStart.String()) list, err := q.GetShoppingList(ctx, weekStart.String())
if err != nil && !errors.Is(err, sql.ErrNoRows) { 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 { if err != nil {
return Generated{}, err return Generated{}, err
} }
if out.List.Title == nil {
s.titleList(ctx, &out.List, revision, names)
}
return out, nil 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 // Get returns the Supply run of the week starting weekStart, as stored: a
// week with none is ErrListNotFound. // week with none is ErrListNotFound.
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) { 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 // wanted reads plan planID's days and their dinners' ingredients: the
// groups the list must have, by date, and the days that get none. // groups the list must have, by date, the days that get none, and the
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, error) { // 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) days, err := q.ListPlanDays(ctx, planID)
if err != nil { 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 ( var (
groups []wantGroup groups []wantGroup
skipped []SkippedDay skipped []SkippedDay
names []string
) )
for _, day := range days { for _, day := range days {
d := day.Dinner d := day.Dinner
names = append(names, d.Name)
skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name} skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name}
if !d.RecipeID.Valid { if !d.RecipeID.Valid {
skip.Reason = SkipNoRecipe 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) recipe, err := q.GetRecipe(ctx, d.RecipeID.String)
if err != nil { 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) ings, err := q.ListRecipeIngredients(ctx, recipe.ID)
if err != nil { 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 { if len(ings) == 0 {
skip.Reason = SkipNoIngredients skip.Reason = SkipNoIngredients
@@ -401,13 +478,13 @@ func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []S
for _, ing := range ings { for _, ing := range ings {
it, err := item(ing, manual) it, err := item(ing, manual)
if err != nil { 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) g.items = append(g.items, it)
} }
groups = append(groups, g) 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 // item is the item for ingredient ing of a recipe entered by hand
+48 -2
View File
@@ -1,6 +1,8 @@
package shopping_test package shopping_test
import ( import (
"bytes"
"context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -8,10 +10,12 @@ import (
"log/slog" "log/slog"
"reflect" "reflect"
"strings" "strings"
"sync"
"testing" "testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "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/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/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
@@ -42,6 +46,31 @@ type env struct {
planID string planID string
// ids are the dinners' ids by name. // ids are the dinners' ids by name.
ids map[string]string 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 { 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 { if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) 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 // 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())
}
}
+7
View File
@@ -81,3 +81,10 @@ WHERE shopping_items.id = ?;
UPDATE shopping_items UPDATE shopping_items
SET needed = ?, updated_at = ? SET needed = ?, updated_at = ?
WHERE id = ?; 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;