Introduce ResolvedParams field to Recipe struct for storing resolved parameter values from defaults and user overrides. Update loader to populate and use it for template rendering. Adjust runner to use ResolvedParams for root path and generalize file discovery.
124 lines
2.9 KiB
Go
124 lines
2.9 KiB
Go
package recipe
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
// stepRe still finds the headings (this one is solid)
|
|
stepRe = regexp.MustCompile(`(?m)^### Step (\d+): (.+)$`)
|
|
)
|
|
|
|
func Load(path string, userParams map[string]any) (*Recipe, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parts := bytes.SplitN(b, []byte("---"), 3)
|
|
if len(parts) < 3 {
|
|
return nil, fmt.Errorf("missing YAML frontmatter")
|
|
}
|
|
|
|
var r Recipe
|
|
if err := yaml.Unmarshal(parts[1], &r); err != nil {
|
|
return nil, fmt.Errorf("yaml parse: %w", err)
|
|
}
|
|
|
|
// Apply defaults + user --param overrides
|
|
if r.Parameters == nil {
|
|
r.Parameters = make(map[string]Parameter)
|
|
}
|
|
r.ResolvedParams = make(map[string]any)
|
|
for name, p := range r.Parameters {
|
|
if v, ok := userParams[name]; ok {
|
|
r.ResolvedParams[name] = v
|
|
} else if p.Default != nil {
|
|
r.ResolvedParams[name] = p.Default
|
|
}
|
|
}
|
|
|
|
// Render templates with resolved values
|
|
tpl, err := template.New("recipe").Parse(string(parts[2]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var rendered bytes.Buffer
|
|
if err := tpl.Execute(&rendered, r.ResolvedParams); err != nil {
|
|
return nil, err
|
|
}
|
|
body := rendered.String()
|
|
|
|
// Extract Overview
|
|
if idx := strings.Index(body, "## Execution Steps"); idx != -1 {
|
|
r.Overview = strings.TrimSpace(body[:idx])
|
|
}
|
|
|
|
// Extract steps with robust multi-line parsing
|
|
matches := stepRe.FindAllStringSubmatch(body, -1)
|
|
for i, m := range matches {
|
|
stepNum := i + 1
|
|
title := m[2]
|
|
|
|
start := strings.Index(body, m[0])
|
|
end := len(body)
|
|
if i+1 < len(matches) {
|
|
nextStart := strings.Index(body[start:], matches[i+1][0])
|
|
end = start + nextStart
|
|
}
|
|
|
|
section := body[start:end]
|
|
|
|
step := Step{Number: stepNum, Title: title}
|
|
|
|
// Simple, reliable label-based parsing (handles multi-line + blank lines)
|
|
labels := []string{"**Objective:**", "**Instructions:**", "**Expected output:**"}
|
|
for _, label := range labels {
|
|
labelStart := strings.Index(section, label)
|
|
if labelStart == -1 {
|
|
continue
|
|
}
|
|
contentStart := labelStart + len(label)
|
|
contentEnd := len(section)
|
|
|
|
// Find next label or end of section
|
|
for _, nextLabel := range labels {
|
|
next := strings.Index(section[contentStart:], nextLabel)
|
|
if next != -1 {
|
|
contentEnd = contentStart + next
|
|
break
|
|
}
|
|
}
|
|
|
|
content := strings.TrimSpace(section[contentStart:contentEnd])
|
|
|
|
switch label {
|
|
case "**Objective:**":
|
|
step.Objective = content
|
|
case "**Instructions:**":
|
|
step.Instructions = content
|
|
case "**Expected output:**":
|
|
step.Expected = content
|
|
}
|
|
}
|
|
|
|
r.Steps = append(r.Steps, step)
|
|
}
|
|
|
|
// Final summary (everything after last step)
|
|
if len(matches) > 0 {
|
|
lastMatch := matches[len(matches)-1][0]
|
|
lastIdx := strings.LastIndex(body, lastMatch)
|
|
r.FinalSummaryPrompt = strings.TrimSpace(body[lastIdx+len(lastMatch):])
|
|
}
|
|
|
|
return &r, nil
|
|
}
|