Feature 17: Shopping List and Supply Archive (#140)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #140.
This commit is contained in:
@@ -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
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
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
|
||||
Reference in New Issue
Block a user