Address review findings on the normalize_recipe skill: - Ingredient order: an ingredient is out of order when its name has more of its words in another line than in its own, not merely when it shares no word with its own line. Swapping "olive oil" and "sesame oil" now fails validation and triggers the repair turn. - Required fields: Ingredient and NormalizedRecipe decode through UnmarshalJSON that requires every schema field and rejects unknown ones; qty, unit and note may not be omitted or null. Such a reply fails to decode and triggers the repair turn. - Zero total: a present JSON-LD TotalMinutes that is not positive is rejected by PromptInput before any model call, instead of falling back to the model's minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
12 KiB
Go
368 lines
12 KiB
Go
// Package skills holds the concrete AI skills run by package harness. Each
|
|
// skill's prompt template is in package prompts.
|
|
package skills
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
)
|
|
|
|
// RecipeText is what normalize_recipe reads: the parts of a recipe page's
|
|
// schema.org Recipe (see recipes.Extract) that it normalizes.
|
|
type RecipeText struct {
|
|
Title string
|
|
// Ingredients are the raw ingredient lines, in order. There is at
|
|
// least one and none is blank.
|
|
Ingredients []string
|
|
// Steps are the instructions, in order; there may be none.
|
|
Steps []string
|
|
// PrepMinutes, CookMinutes and TotalMinutes are the JSON-LD times,
|
|
// nil when the page gives none. A TotalMinutes that is present must be
|
|
// positive: a recipe cannot take no time, so NormalizeRecipe rejects a
|
|
// zero total rather than guess one.
|
|
PrepMinutes *int
|
|
CookMinutes *int
|
|
TotalMinutes *int
|
|
}
|
|
|
|
// Ingredient is one normalized ingredient line. Qty, Unit and Note are
|
|
// empty when the line gives none; the model must still give all four, so a
|
|
// reply leaving one out or null does not decode.
|
|
type Ingredient struct {
|
|
Name string `json:"name"`
|
|
Qty string `json:"qty"`
|
|
Unit string `json:"unit"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
// NormalizedRecipe is normalize_recipe's output.
|
|
type NormalizedRecipe struct {
|
|
// Ingredients holds one ingredient per input line, in the same order.
|
|
Ingredients []Ingredient `json:"ingredients"`
|
|
// Method is one of stovetop, oven, crockpot, grill and other.
|
|
Method string `json:"method"`
|
|
// Difficulty is easy, medium or hard. The model may leave it empty;
|
|
// NormalizeRecipe always returns one.
|
|
Difficulty string `json:"difficulty"`
|
|
// MinutesTotal is prep to table in minutes, nil when unknown.
|
|
MinutesTotal *int `json:"minutes_total"`
|
|
}
|
|
|
|
// NormalizeRecipe runs normalize_recipe v1 on in and returns its output.
|
|
// The run is at temperature 0 whatever cfg.Settings.Temperature says: the
|
|
// same page must normalize the same way every time. scope names the recipe,
|
|
// e.g. its source URL (see harness.Run).
|
|
//
|
|
// Values the code can derive win over the model's: the JSON-LD
|
|
// TotalMinutes, when given, is minutes_total; a title or step mentioning a slow cooker
|
|
// or crock pot makes the method crockpot; and a difficulty the model left
|
|
// out is derived from the step count and minutes.
|
|
func NormalizeRecipe(ctx context.Context, cfg harness.Config, scope string, in RecipeText) (NormalizedRecipe, error) {
|
|
cfg.Settings.Temperature = 0
|
|
return harness.Run(ctx, cfg, normalizeRecipe{in: in}, scope, in)
|
|
}
|
|
|
|
// normalizeRecipe is the skill. It holds its input because validation and
|
|
// post-processing compare the output with it.
|
|
type normalizeRecipe struct {
|
|
in RecipeText
|
|
}
|
|
|
|
var _ harness.Skill[RecipeText, NormalizedRecipe] = normalizeRecipe{}
|
|
|
|
func (normalizeRecipe) Name() string { return "normalize_recipe" }
|
|
|
|
func (normalizeRecipe) Version() int { return 1 }
|
|
|
|
func (normalizeRecipe) Timeout() time.Duration { return harness.DefaultTimeout }
|
|
|
|
// recipePrompt is the template data of normalize_recipe.v1.tmpl. Lists are
|
|
// numbered and every line is on one line.
|
|
type recipePrompt struct {
|
|
Title string
|
|
Times string
|
|
Ingredients []string
|
|
Steps []string
|
|
}
|
|
|
|
func (normalizeRecipe) PromptInput(in RecipeText) (harness.PromptInput, error) {
|
|
title := oneLine(in.Title)
|
|
if title == "" {
|
|
return harness.PromptInput{}, errors.New("recipe has no title")
|
|
}
|
|
if len(in.Ingredients) == 0 {
|
|
return harness.PromptInput{}, errors.New("recipe has no ingredient lines")
|
|
}
|
|
if in.TotalMinutes != nil && *in.TotalMinutes <= 0 {
|
|
return harness.PromptInput{}, fmt.Errorf("recipe total time is %d minutes; a given total must be positive", *in.TotalMinutes)
|
|
}
|
|
p := recipePrompt{Title: title}
|
|
for i, line := range in.Ingredients {
|
|
line = oneLine(line)
|
|
if line == "" {
|
|
return harness.PromptInput{}, fmt.Errorf("ingredient line %d is blank", i+1)
|
|
}
|
|
p.Ingredients = append(p.Ingredients, fmt.Sprintf("%d. %s", i+1, line))
|
|
}
|
|
for i, step := range in.Steps {
|
|
step = oneLine(step)
|
|
if step == "" {
|
|
return harness.PromptInput{}, fmt.Errorf("step %d is blank", i+1)
|
|
}
|
|
p.Steps = append(p.Steps, fmt.Sprintf("%d. %s", i+1, step))
|
|
}
|
|
var times []string
|
|
for _, t := range []struct {
|
|
label string
|
|
minutes *int
|
|
}{{"prep", in.PrepMinutes}, {"cook", in.CookMinutes}, {"total", in.TotalMinutes}} {
|
|
if t.minutes != nil {
|
|
times = append(times, fmt.Sprintf("%s %d min", t.label, *t.minutes))
|
|
}
|
|
}
|
|
p.Times = strings.Join(times, ", ")
|
|
return harness.PromptInput{Data: p}, nil
|
|
}
|
|
|
|
// oneLine collapses every run of white space in s, line breaks included,
|
|
// to one space.
|
|
func oneLine(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
func (normalizeRecipe) Schema() llm.JSONSchema {
|
|
return llm.JSONSchema{Name: "normalize_recipe", Schema: json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"ingredients": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"qty": {"type": "string"},
|
|
"unit": {"type": "string"},
|
|
"note": {"type": "string"}
|
|
},
|
|
"required": ["name", "qty", "unit", "note"],
|
|
"additionalProperties": false
|
|
}
|
|
},
|
|
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
|
"difficulty": {"type": ["string", "null"], "enum": ["easy", "medium", "hard", null]},
|
|
"minutes_total": {"type": ["integer", "null"]}
|
|
},
|
|
"required": ["ingredients", "method", "difficulty", "minutes_total"],
|
|
"additionalProperties": false
|
|
}`)}
|
|
}
|
|
|
|
// UnmarshalJSON decodes an ingredient, requiring each of its four fields
|
|
// as the schema does: encoding/json would otherwise read a missing or null
|
|
// one as empty.
|
|
func (ing *Ingredient) UnmarshalJSON(data []byte) error {
|
|
if err := requireFields(data, nil, "name", "qty", "unit", "note"); err != nil {
|
|
return fmt.Errorf("ingredient %s", err)
|
|
}
|
|
type plain Ingredient
|
|
return json.Unmarshal(data, (*plain)(ing))
|
|
}
|
|
|
|
// UnmarshalJSON decodes the output, requiring each of its fields as the
|
|
// schema does. Only difficulty and minutes_total may be null.
|
|
func (r *NormalizedRecipe) UnmarshalJSON(data []byte) error {
|
|
if err := requireFields(data, []string{"difficulty", "minutes_total"}, "ingredients", "method", "difficulty", "minutes_total"); err != nil {
|
|
return fmt.Errorf("output %s", err)
|
|
}
|
|
type plain NormalizedRecipe
|
|
return json.Unmarshal(data, (*plain)(r))
|
|
}
|
|
|
|
// requireFields checks that the JSON object data has exactly the fields
|
|
// named, none of them null unless listed in nullable.
|
|
func requireFields(data []byte, nullable []string, fields ...string) error {
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return err
|
|
}
|
|
if obj == nil {
|
|
return errors.New("is null, not an object")
|
|
}
|
|
for _, f := range fields {
|
|
v, ok := obj[f]
|
|
if !ok {
|
|
return fmt.Errorf("has no %q field; give every field the schema requires", f)
|
|
}
|
|
if bytes.Equal(bytes.TrimSpace(v), []byte("null")) && !slices.Contains(nullable, f) {
|
|
return fmt.Errorf("field %q is null, which the schema does not allow", f)
|
|
}
|
|
}
|
|
for f := range obj {
|
|
if !slices.Contains(fields, f) {
|
|
return fmt.Errorf("has unknown field %q", f)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
|
|
difficulties = []string{"easy", "medium", "hard"}
|
|
)
|
|
|
|
func (s normalizeRecipe) Validate(out NormalizedRecipe) []string {
|
|
var problems []string
|
|
lines := s.in.Ingredients
|
|
if len(out.Ingredients) != len(lines) {
|
|
problems = append(problems, fmt.Sprintf("ingredients has %d entries but there are %d ingredient lines; give exactly one ingredient per line, in order",
|
|
len(out.Ingredients), len(lines)))
|
|
}
|
|
for i, ing := range out.Ingredients {
|
|
if strings.TrimSpace(ing.Name) == "" {
|
|
problems = append(problems, fmt.Sprintf("ingredient %d has an empty name; name the ingredient", i+1))
|
|
}
|
|
}
|
|
if len(out.Ingredients) == len(lines) {
|
|
problems = append(problems, orderProblems(out.Ingredients, lines)...)
|
|
}
|
|
if !slices.Contains(methods, out.Method) {
|
|
problems = append(problems, fmt.Sprintf("method %q is not one of %s", out.Method, strings.Join(methods, ", ")))
|
|
}
|
|
if out.Difficulty != "" && !slices.Contains(difficulties, out.Difficulty) {
|
|
problems = append(problems, fmt.Sprintf("difficulty %q is not one of %s, or null", out.Difficulty, strings.Join(difficulties, ", ")))
|
|
}
|
|
if out.MinutesTotal != nil && *out.MinutesTotal <= 0 && jsonLDMinutes(s.in) == nil {
|
|
problems = append(problems, fmt.Sprintf("minutes_total %d is not positive; give the minutes or null", *out.MinutesTotal))
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// orderProblems finds ingredients out of input order: an ingredient whose
|
|
// name has more of its words in another line than in its own. Sharing a
|
|
// word is not enough: "sesame oil" on the line "2 tbsp olive oil" has one
|
|
// of its two words there but both in "1 tbsp sesame oil", so it is out of
|
|
// order. Ingredients that match no line at all, e.g. a renamed one, are
|
|
// accepted, as are ties, e.g. a repeated line.
|
|
func orderProblems(ings []Ingredient, lines []string) []string {
|
|
var problems []string
|
|
for i, ing := range ings {
|
|
name := words(ing.Name)
|
|
own := matchedWords(name, words(lines[i]))
|
|
best, bestLine := own, i
|
|
for j, line := range lines {
|
|
if n := matchedWords(name, words(line)); n > best {
|
|
best, bestLine = n, j
|
|
}
|
|
}
|
|
if bestLine != i {
|
|
problems = append(problems, fmt.Sprintf("ingredient %d %q matches line %d %q, not line %d %q; keep the ingredients in line order",
|
|
i+1, ing.Name, bestLine+1, lines[bestLine], i+1, lines[i]))
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// words returns the lower-case words of s of three or more letters.
|
|
func words(s string) []string {
|
|
var out []string
|
|
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) }) {
|
|
if len([]rune(w)) >= 3 {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// matchedWords counts the words of name that are in line, up to a suffix,
|
|
// as "tomato" and "tomatoes" are the same word.
|
|
func matchedWords(name, line []string) int {
|
|
n := 0
|
|
for _, x := range name {
|
|
if slices.ContainsFunc(line, func(y string) bool { return strings.HasPrefix(x, y) || strings.HasPrefix(y, x) }) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// slowCooker matches a mention of a slow cooker or crock pot.
|
|
var slowCooker = regexp.MustCompile(`(?i)\b(slow[\s-]?cooker|crock[\s-]?pot)`)
|
|
|
|
func (s normalizeRecipe) PostProcess(out NormalizedRecipe) (NormalizedRecipe, error) {
|
|
ings := make([]Ingredient, len(out.Ingredients))
|
|
for i, ing := range out.Ingredients {
|
|
ings[i] = Ingredient{
|
|
Name: oneLine(ing.Name),
|
|
Qty: oneLine(ing.Qty),
|
|
Unit: oneLine(ing.Unit),
|
|
Note: oneLine(ing.Note),
|
|
}
|
|
}
|
|
out.Ingredients = ings
|
|
if m := jsonLDMinutes(s.in); m != nil {
|
|
out.MinutesTotal = m
|
|
}
|
|
if mentionsSlowCooker(s.in) {
|
|
out.Method = "crockpot"
|
|
}
|
|
if out.Difficulty == "" {
|
|
minutes := 0
|
|
if out.MinutesTotal != nil {
|
|
minutes = *out.MinutesTotal
|
|
}
|
|
out.Difficulty = deriveDifficulty(len(s.in.Steps), minutes, out.Method == "crockpot")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// jsonLDMinutes returns a copy of the JSON-LD total time, or nil when the
|
|
// page gives none. PromptInput has already rejected a total that is not
|
|
// positive.
|
|
func jsonLDMinutes(in RecipeText) *int {
|
|
if in.TotalMinutes == nil {
|
|
return nil
|
|
}
|
|
m := *in.TotalMinutes
|
|
return &m
|
|
}
|
|
|
|
func mentionsSlowCooker(in RecipeText) bool {
|
|
if slowCooker.MatchString(in.Title) {
|
|
return true
|
|
}
|
|
for _, step := range in.Steps {
|
|
if slowCooker.MatchString(step) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// deriveDifficulty grades a recipe by its step count and minutes, 0 when
|
|
// unknown. A crockpot recipe's minutes are mostly unattended, so they do
|
|
// not count.
|
|
func deriveDifficulty(steps, minutes int, crockpot bool) string {
|
|
if crockpot {
|
|
minutes = 0
|
|
}
|
|
switch {
|
|
case steps > 10 || minutes > 90:
|
|
return "hard"
|
|
case steps <= 5 && minutes <= 45:
|
|
return "easy"
|
|
default:
|
|
return "medium"
|
|
}
|
|
}
|