Add the archive_title skill and title Supply runs on generation
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:
+13
-1
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user