Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
298 lines
11 KiB
Go
298 lines
11 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
|
|
// dinners screen.
|
|
const (
|
|
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
|
|
QuickCrockpot = "crockpot"
|
|
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
|
|
QuickUnder30 = "under30"
|
|
)
|
|
|
|
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
|
|
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
|
|
|
|
const (
|
|
// QuickIdeasRequested is how many ideas quick_ideas asks the model
|
|
// for: more than a page holds, since validation, the exclude list,
|
|
// grounding and the kind's check drop some.
|
|
QuickIdeasRequested = 12
|
|
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
|
|
QuickIdeasKept = 6
|
|
// MaxExclude is the most names already shown a request may exclude.
|
|
MaxExclude = 60
|
|
// ExcludeLimit is the most characters one excluded name may have.
|
|
ExcludeLimit = 120
|
|
)
|
|
|
|
// QuickIdeasRequest is what quick_ideas reads.
|
|
type QuickIdeasRequest struct {
|
|
// Context is the household context (see harness.BuildContext): the
|
|
// ideas are picked from the household's tastes.
|
|
Context harness.HouseholdContext
|
|
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
|
|
// the caller enforces it after grounding, from the grounded recipe.
|
|
Kind string
|
|
// Exclude are the names already shown to the household, as
|
|
// CleanExclude returns them. No idea repeating one is returned.
|
|
Exclude []string
|
|
}
|
|
|
|
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
|
|
// are the model's estimates until the idea is grounded in a recipe.
|
|
type QuickIdea struct {
|
|
Name string `json:"name"`
|
|
EstMinutes int `json:"est_minutes"`
|
|
// Method is one of stovetop, oven, crockpot, grill and other.
|
|
Method string `json:"method"`
|
|
// Difficulty is easy, medium or hard.
|
|
Difficulty string `json:"difficulty"`
|
|
// SearchQuery is a web search query that finds a recipe for the dinner.
|
|
SearchQuery string `json:"search_query"`
|
|
}
|
|
|
|
// QuickIdeaList is quick_ideas' output.
|
|
type QuickIdeaList struct {
|
|
// Ideas are the ideas that passed every rule, in the model's order.
|
|
Ideas []QuickIdea `json:"ideas"`
|
|
// Dropped are the ideas the model gave that broke a rule, in the
|
|
// model's order. It is never part of the model's reply.
|
|
Dropped []DroppedIdea `json:"-"`
|
|
}
|
|
|
|
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
|
|
// excluded name nor each other (harness.Similar, on the normalized
|
|
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
|
|
// decided here: the model's estimates never decide it, the grounded
|
|
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
|
|
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
|
|
// fails before any LLM call.
|
|
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
|
|
if err := CheckQuickKind(in.Kind); err != nil {
|
|
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
|
}
|
|
exclude, err := CleanExclude(in.Exclude)
|
|
if err != nil {
|
|
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
|
}
|
|
in.Exclude = exclude
|
|
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
|
|
}
|
|
|
|
// QuickIdeasScope is the harness scope of a request: its kind and its
|
|
// normalized excluded names, in order, so two requests share a run only
|
|
// when they ask for the same page.
|
|
func QuickIdeasScope(in QuickIdeasRequest) string {
|
|
parts := make([]string, len(in.Exclude))
|
|
for i, name := range in.Exclude {
|
|
parts[i] = harness.Normalize(name)
|
|
}
|
|
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
|
|
}
|
|
|
|
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
|
|
// is no default kind.
|
|
func CheckQuickKind(kind string) error {
|
|
if !slices.Contains(QuickKinds, kind) {
|
|
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanExclude returns the excluded names with each on one line, never
|
|
// nil, or why they cannot be used: there are more than MaxExclude, or a
|
|
// name is blank, longer than ExcludeLimit characters, has nothing left
|
|
// once normalized (harness.Normalize), or normalizes the same as an
|
|
// earlier one. An empty list, the first page's, is valid.
|
|
func CleanExclude(list []string) ([]string, error) {
|
|
if len(list) > MaxExclude {
|
|
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
|
|
}
|
|
out := make([]string, len(list))
|
|
seen := map[string]string{}
|
|
for i, name := range list {
|
|
name = oneLine(name)
|
|
switch {
|
|
case name == "":
|
|
return nil, fmt.Errorf("excluded name %d is blank", i+1)
|
|
case utf8.RuneCountInString(name) > ExcludeLimit:
|
|
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
|
|
}
|
|
n := harness.Normalize(name)
|
|
if n == "" {
|
|
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
|
|
}
|
|
if first, ok := seen[n]; ok {
|
|
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
|
|
}
|
|
seen[n] = name
|
|
out[i] = name
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// quickIdeas is the skill. It holds its input because post-processing
|
|
// compares the output with it.
|
|
type quickIdeas struct {
|
|
in QuickIdeasRequest
|
|
}
|
|
|
|
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
|
|
|
|
func (quickIdeas) Name() string { return "quick_ideas" }
|
|
|
|
func (quickIdeas) Version() int { return 1 }
|
|
|
|
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
|
|
|
|
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
|
|
type quickIdeasPrompt struct {
|
|
// Count is how many ideas to ask for.
|
|
Count int
|
|
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
|
|
Crockpot bool
|
|
}
|
|
|
|
// PromptInput puts the household context, then the names already shown,
|
|
// in the household context block.
|
|
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
|
|
if err := CheckQuickKind(in.Kind); err != nil {
|
|
return harness.PromptInput{}, err
|
|
}
|
|
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
|
|
return harness.PromptInput{
|
|
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
|
|
Context: sections,
|
|
}, nil
|
|
}
|
|
|
|
func (quickIdeas) Schema() llm.JSONSchema {
|
|
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"ideas": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"est_minutes": {"type": "integer"},
|
|
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
|
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
|
"search_query": {"type": "string"}
|
|
},
|
|
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
|
|
"additionalProperties": false
|
|
}
|
|
}
|
|
},
|
|
"required": ["ideas"],
|
|
"additionalProperties": false
|
|
}`)}
|
|
}
|
|
|
|
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
|
// schema does: encoding/json would otherwise read a missing or null one as
|
|
// empty.
|
|
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
|
|
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
|
return fmt.Errorf("idea %s", err)
|
|
}
|
|
type plain QuickIdea
|
|
return json.Unmarshal(data, (*plain)(i))
|
|
}
|
|
|
|
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
|
// does.
|
|
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
|
|
if err := requireFields(data, nil, "ideas"); err != nil {
|
|
return fmt.Errorf("output %s", err)
|
|
}
|
|
type plain QuickIdeaList
|
|
return json.Unmarshal(data, (*plain)(out))
|
|
}
|
|
|
|
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
|
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
|
// dropped by PostProcess instead.
|
|
func (quickIdeas) Validate(out QuickIdeaList) []string {
|
|
var problems []string
|
|
switch n := len(out.Ideas); {
|
|
case n == 0 && len(out.Dropped) == 0:
|
|
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
|
|
case n > QuickIdeasRequested:
|
|
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
|
|
}
|
|
for i, idea := range out.Ideas {
|
|
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
|
if strings.TrimSpace(f.value) == "" {
|
|
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
|
}
|
|
}
|
|
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
|
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
|
}
|
|
if !slices.Contains(methods, idea.Method) {
|
|
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
|
}
|
|
if idea.EstMinutes <= 0 {
|
|
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
|
}
|
|
if !slices.Contains(difficulties, idea.Difficulty) {
|
|
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// PostProcess puts every idea's text on one line and moves each that
|
|
// repeats an excluded name or an earlier idea (harness.Similar, which
|
|
// compares normalized names) to Dropped. The model's method and minutes
|
|
// are kept as estimates and never decide the kind.
|
|
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
|
|
result := QuickIdeaList{Ideas: []QuickIdea{}}
|
|
for _, idea := range out.Ideas {
|
|
idea = QuickIdea{
|
|
Name: oneLine(idea.Name),
|
|
EstMinutes: idea.EstMinutes,
|
|
Method: idea.Method,
|
|
Difficulty: idea.Difficulty,
|
|
SearchQuery: oneLine(idea.SearchQuery),
|
|
}
|
|
if why := s.drop(idea, result.Ideas); why != "" {
|
|
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
|
continue
|
|
}
|
|
result.Ideas = append(result.Ideas, idea)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// drop returns why idea must be dropped, or "" when it may be kept.
|
|
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
|
|
for _, name := range s.in.Exclude {
|
|
if harness.Similar(idea.Name, name) {
|
|
return fmt.Sprintf("repeats %q, already shown", name)
|
|
}
|
|
}
|
|
for _, k := range kept {
|
|
if harness.Similar(idea.Name, k.Name) {
|
|
return fmt.Sprintf("repeats the idea %q", k.Name)
|
|
}
|
|
}
|
|
return ""
|
|
}
|