Files
MealQuest/internal/ai/skills/suggest_side_quests.go
T
dev_agentandClaude Opus 5.5 70a4e540aa
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m26s
Ignore spacing beside a Regular's outer punctuation in a mention
A mention took in a stored name's leading or trailing punctuation only
where the text repeated the stored spacing exactly, although the rule
says spacing is ignored. With Regulars "Beef Chili !" and "Chili!", the
reason "Like your Beef Chili!" left the first mention at "Beef Chili",
so a citation of "Chili!" passed. Match the punctuation with any spacing
beside it, then apply the same border check to the extended mention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:50:36 -04:00

414 lines
15 KiB
Go

package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// ReasonLimit is the most characters a Side quest's reason, its "Why
// this?", may have.
const ReasonLimit = 180
// SideQuestsRequest is what suggest_side_quests reads.
type SideQuestsRequest struct {
// Context is the household context (see harness.BuildContext). It must
// hold at least one Regular, since every Side quest cites one.
Context harness.HouseholdContext
// Needed is how many Side quests the caller wants to keep. The skill
// asks the model for twice as many, since validation and grounding
// drop some. It must be positive.
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds the name of every passed Side quest, not only the
// recent ones Context holds.
Declined []string
}
// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty
// are the model's estimates until the dinner is grounded in a recipe.
type SideQuest struct {
Name string `json:"name"`
Cuisine string `json:"cuisine"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
EstMinutes int `json:"est_minutes"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Reason is the "Why this?": at most ReasonLimit characters, naming at
// least one of CitedRegulars.
Reason string `json:"reason"`
// CitedRegulars are names of the household's Regulars, as the
// household writes them.
CitedRegulars []string `json:"cited_regulars"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
}
// SideQuests is suggest_side_quests' output.
type SideQuests struct {
// Suggestions are the Side quests that passed every rule, in the
// model's order. There may be more or fewer than Needed.
Suggestions []SideQuest `json:"suggestions"`
// Dropped are the Side quests the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedSideQuest `json:"-"`
}
// DroppedSideQuest is a Side quest dropped by PostProcess.
type DroppedSideQuest struct {
Name string
// Why says which rule it broke.
Why string
}
// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side
// quests that cite only real Regulars, give a short reason naming one of
// them, and repeat no Regular, Quest log dinner, pending or passed Side
// quest, nor each other. The rest are in Dropped. scope names the batch
// (see harness.Run).
func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) {
return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in)
}
// suggestSideQuests is the skill. It holds its input because validation
// and post-processing compare the output with it.
type suggestSideQuests struct {
in SideQuestsRequest
}
var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{}
func (suggestSideQuests) Name() string { return "suggest_side_quests" }
func (suggestSideQuests) Version() int { return 1 }
func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout }
// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl.
type sideQuestsPrompt struct {
// Count is how many Side quests to ask for.
Count int
}
// requested is how many Side quests the model is asked for.
func (s suggestSideQuests) requested() int { return 2 * s.in.Needed }
func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) {
if in.Needed <= 0 {
return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed)
}
if len(in.Context.Regulars) == 0 {
return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on")
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending})
return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil
}
func (suggestSideQuests) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"cuisine": {"type": "string"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"est_minutes": {"type": "integer"},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"reason": {"type": "string"},
"cited_regulars": {"type": "array", "items": {"type": "string"}},
"search_query": {"type": "string"}
},
"required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"],
"additionalProperties": false
}
}
},
"required": ["suggestions"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes a Side quest, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (q *SideQuest) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil {
return fmt.Errorf("suggestion %s", err)
}
type plain SideQuest
return json.Unmarshal(data, (*plain)(q))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *SideQuests) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "suggestions"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain SideQuests
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: a wrong number
// of Side quests or one that is malformed. A well-formed Side quest that
// breaks a rule is dropped by PostProcess instead.
func (s suggestSideQuests) Validate(out SideQuests) []string {
var problems []string
switch n := len(out.Suggestions); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested()))
case n > s.requested():
problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested()))
}
for i, q := range out.Suggestions {
for _, f := range []struct{ field, value string }{
{"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery},
} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name))
}
if !slices.Contains(methods, q.Method) {
problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", ")))
}
if q.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes))
}
if !slices.Contains(difficulties, q.Difficulty) {
problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess tidies every Side quest's text, writes its cited Regulars as
// the household does, and moves each one that breaks a rule to Dropped
// (see drop).
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}}
var names []string
for _, r := range s.in.Context.Regulars {
regulars.add(r.Name)
names = append(names, r.Name)
}
regulars.all = names
known := harness.NewDeduper(harness.DedupeSources{
Regulars: names,
QuestLog: s.in.Context.QuestLog,
Pending: s.in.Pending,
Declined: s.in.Declined,
})
result := SideQuests{Suggestions: []SideQuest{}}
for _, q := range out.Suggestions {
q = SideQuest{
Name: oneLine(q.Name),
Cuisine: oneLine(q.Cuisine),
Method: q.Method,
EstMinutes: q.EstMinutes,
Difficulty: q.Difficulty,
Reason: oneLine(q.Reason),
CitedRegulars: q.CitedRegulars,
SearchQuery: oneLine(q.SearchQuery),
}
cited, why := drop(q, regulars, known, result.Suggestions)
if why != "" {
result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why})
continue
}
q.CitedRegulars = cited
result.Suggestions = append(result.Suggestions, q)
}
return result, nil
}
// regularNames finds the Regular a cited name means. Names need not be
// unique, even ignoring case, so each key holds every Regular with it.
type regularNames struct {
// exact is keyed by the name in lower case on one line.
exact map[string][]string
// normalized is keyed by harness.Normalize of the name.
normalized map[string][]string
// all holds every Regular's name, in the household's order.
all []string
}
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
func (r regularNames) add(name string) {
r.exact[exactName(name)] = append(r.exact[exactName(name)], name)
if n := harness.Normalize(name); n != "" {
r.normalized[n] = append(r.normalized[n], name)
}
}
// find returns the one Regular cited means, or "" and why there is none.
// A Regular named exactly cited, ignoring case and spacing, comes first;
// failing that, cited may name a Regular by its normalized name.
func (r regularNames) find(cited string) (string, string) {
matches := r.exact[exactName(cited)]
if len(matches) == 0 {
matches = r.normalized[harness.Normalize(cited)]
}
switch len(matches) {
case 0:
return "", fmt.Sprintf("cites %q, which is not a Regular", cited)
case 1:
return matches[0], ""
default:
return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches)
}
}
// drop returns why q must be dropped, or "" and its cited Regulars as the
// household writes them when it may be kept. q is dropped when it repeats
// a known dinner or a Side quest already kept, cites no Regular or one
// regulars finds no single Regular for, or has a reason over ReasonLimit
// characters or naming none of its cited Regulars.
func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) {
if known.IsDuplicate(q.Name) {
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
}
for _, k := range kept {
if harness.Similar(q.Name, k.Name) {
return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name)
}
}
if len(q.CitedRegulars) == 0 {
return nil, "cites no Regular"
}
var cited []string
for _, c := range q.CitedRegulars {
name, why := regulars.find(c)
if why != "" {
return nil, why
}
cited = append(cited, name)
}
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
}
named := regulars.named(q.Reason)
if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) {
return nil, "reason names none of its cited Regulars"
}
return cited, ""
}
// named returns the Regulars text names, by one rule. A mention of a
// Regular is a place where text spells its name, ignoring case and spacing,
// with no letter, mark or digit just outside it: the start or end of text,
// white space, punctuation or a symbol must border it, so "Beef
// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef
// Chilis" does not, nor does "Cafe" with a combining accent after it
// mention "Cafe". Punctuation at either end of the name, with any spacing
// beside it, is optional, and a mention takes it in only where text spells
// it, spaced in any way, and the border still holds after it: "Easy Beef
// Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili !"
// with its "!", and by "Easy Beef Chili!x" without its "!". A
// combining mark is part of its letter, never punctuation. A mention names
// its Regular unless a mention of a different Regular covers the same text
// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef
// Chili", and text that two Regulars spell alike names neither there.
func (r regularNames) named(text string) []string {
type mention struct {
name string
start, end int
}
text = exactName(text)
bordered := func(start, end int) bool {
before, _ := utf8.DecodeLastRuneInString(text[:start])
after, _ := utf8.DecodeRuneInString(text[end:])
return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after))
}
var mentions []mention
for _, name := range r.all {
full := exactName(name)
core := strings.TrimFunc(full, isOuter)
if core == "" {
continue
}
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))]
trail := full[len(lead)+len(core):]
for at := 0; ; {
i := strings.Index(text[at:], core)
if i < 0 {
break
}
start, end := at+i, at+i+len(core)
at = start + 1
if !bordered(start, end) {
continue
}
if s := spelledBefore(text, start, lead); s >= 0 && bordered(s, end) {
start = s
}
if e := spelledAfter(text, end, trail); e >= 0 && bordered(start, e) {
end = e
}
mentions = append(mentions, mention{name, start, end})
}
}
var names []string
for _, m := range mentions {
covered := slices.ContainsFunc(mentions, func(o mention) bool {
return o.name != m.name && o.start <= m.start && o.end >= m.end
})
if !covered && !slices.Contains(names, m.name) {
names = append(names, m.name)
}
}
return names
}
// spelledAfter returns where outer ends when text spells it from at,
// ignoring spacing, or -1 when text does not.
func spelledAfter(text string, at int, outer string) int {
for _, c := range strings.Join(strings.Fields(outer), "") {
at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace))
if !strings.HasPrefix(text[at:], string(c)) {
return -1
}
at += utf8.RuneLen(c)
}
return at
}
// spelledBefore returns where outer starts when text spells it up to at,
// ignoring spacing, or -1 when text does not.
func spelledBefore(text string, at int, outer string) int {
punct := []rune(strings.Join(strings.Fields(outer), ""))
for i := len(punct) - 1; i >= 0; i-- {
at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace))
if !strings.HasSuffix(text[:at], string(punct[i])) {
return -1
}
at -= utf8.RuneLen(punct[i])
}
return at
}
// isWord reports whether c is a letter, a mark on one or a digit.
func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) }
// isOuter reports whether c may be left off the end of a name: punctuation
// or white space.
func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }