Files
orchestrator-agent cb451baed4
CI / ci (pull_request) Successful in 5m23s
CI / ci (push) Successful in 5m45s
Feature 17: Shopping List and Supply Archive (#140)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-08 15:16:51 -04:00

149 lines
5.2 KiB
Go

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
}