The skill asks for twice the Side quests needed, using the household context plus the pending Side quests, through an embedded, checksummed prompt template. A malformed reply gets the harness's one repair turn. A well-formed Side quest is dropped (and listed in Dropped) when it repeats a Regular, Quest log dinner, pending or passed name or an earlier Side quest in the batch, cites no Regular or one that is not a Regular, or has a reason over 180 characters or naming none of its cited Regulars. Golden tests use llm.Fake. Maestro task 85a898e1-f1f5-4414-92f3-3277d86afe90. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
10 KiB
Go
275 lines
10 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"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 !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 := map[string]string{} // normalized name -> name
|
|
var names []string
|
|
for _, r := range s.in.Context.Regulars {
|
|
if n := harness.Normalize(r.Name); n != "" {
|
|
regulars[n] = r.Name
|
|
}
|
|
names = append(names, r.Name)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 not
|
|
// in regulars (keyed by normalized name), or has a reason over ReasonLimit
|
|
// characters or naming none of its cited Regulars.
|
|
func drop(q SideQuest, regulars map[string]string, 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, ok := regulars[harness.Normalize(c)]
|
|
if !ok {
|
|
return nil, fmt.Sprintf("cites %q, which is not a Regular", c)
|
|
}
|
|
cited = append(cited, name)
|
|
}
|
|
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
|
|
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
|
|
}
|
|
reason := " " + harness.Normalize(q.Reason) + " "
|
|
if !slices.ContainsFunc(cited, func(name string) bool {
|
|
return strings.Contains(reason, " "+harness.Normalize(name)+" ")
|
|
}) {
|
|
return nil, "reason names none of its cited Regulars"
|
|
}
|
|
return cited, ""
|
|
}
|