normalize_recipe v1 turns a recipe page's title, raw ingredient lines,
steps and JSON-LD times into {ingredients: [{name, qty, unit, note}],
method, difficulty, minutes_total}. skills.NormalizeRecipe always runs it
at temperature 0. Validation requires one named ingredient per input line,
in order, so a mismatch gets the harness's repair turn. Code-derived
values win over the model's: JSON-LD total minutes, crockpot for a slow
cooker or crock pot mention, and a derived difficulty when the model gives
none. Golden tests with the Fake provider cover a typical recipe, a
repaired line-count mismatch, a slow-cooker recipe forced to crockpot and
JSON-LD minutes overriding the model.
Maestro task 9b4db226-9d8f-4251-a5de-adc45713c7bc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
9.6 KiB
Go
309 lines
9.6 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 (
|
|
"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.
|
|
PrepMinutes *int
|
|
CookMinutes *int
|
|
TotalMinutes *int
|
|
}
|
|
|
|
// Ingredient is one normalized ingredient line. Qty, Unit and Note are
|
|
// empty when the line gives none.
|
|
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: a positive JSON-LD
|
|
// TotalMinutes 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")
|
|
}
|
|
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
|
|
}`)}
|
|
}
|
|
|
|
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 shares no word with its own line but does with another line.
|
|
// Ingredients that match no line at all, e.g. a renamed one, are accepted.
|
|
func orderProblems(ings []Ingredient, lines []string) []string {
|
|
var problems []string
|
|
for i, ing := range ings {
|
|
name := words(ing.Name)
|
|
if len(name) == 0 || sharesWord(name, words(lines[i])) {
|
|
continue
|
|
}
|
|
for j, line := range lines {
|
|
if j != i && sharesWord(name, words(line)) {
|
|
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, j+1, line, i+1, lines[i]))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// sharesWord reports whether a word of a and a word of b are the same up
|
|
// to a suffix, as "tomato" and "tomatoes" are.
|
|
func sharesWord(a, b []string) bool {
|
|
for _, x := range a {
|
|
for _, y := range b {
|
|
if strings.HasPrefix(x, y) || strings.HasPrefix(y, x) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 no positive one.
|
|
func jsonLDMinutes(in RecipeText) *int {
|
|
if in.TotalMinutes == nil || *in.TotalMinutes <= 0 {
|
|
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"
|
|
}
|
|
}
|