Skip to content

Commit f67c1ac

Browse files
committed
feat: enhance suggest case generation #357
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5af9038 commit f67c1ac

9 files changed

Lines changed: 1098 additions & 35 deletions

File tree

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ waza new task from-prompt "Explain this code and suggest fixes" evals/code-expla
127127
waza check skills/my-skill
128128

129129
# Suggest an eval suite from SKILL.md
130-
waza suggest skills/my-skill --dry-run
130+
waza suggest skills/my-skill --count 5 --focus edge-fixtures --dry-run
131131
waza suggest skills/my-skill --apply
132132

133133
# Note: 'generate' is available as an alias for 'new' (see below for new command)
@@ -564,23 +564,31 @@ Use an LLM to analyze `SKILL.md` and generate suggested evaluation artifacts.
564564
| Flag | Description |
565565
|------|-------------|
566566
| `--model <model>` | Model to use for suggestions (default: project default model) |
567+
| `--count <n>` | Number of task cases to propose (default: `3`) |
568+
| `--focus <category>` | Focus category: `triggers`, `negative-triggers`, `edge-fixtures`, `do-not-use-for`, or `parameters` |
567569
| `--dry-run` | Print suggested output to stdout (default) |
568570
| `--apply` | Write files to disk |
571+
| `--force` | Overwrite conflicting generated files or task IDs when applying |
569572
| `--output-dir <dir>` | Output directory (default: `<skill-path>/evals`) |
570573
| `--format yaml\|json` | Output format (default: `yaml`) |
571574

572575
**Examples:**
573576
```bash
574577
# Preview generated eval/task/fixture files as YAML
575-
waza suggest skills/code-explainer --dry-run
578+
waza suggest skills/code-explainer --count 5 --focus negative-triggers --dry-run
576579

577580
# Write generated files to disk
578581
waza suggest skills/code-explainer --apply
579582

583+
# Replace conflicting generated task files after review
584+
waza suggest skills/code-explainer --apply --force
585+
580586
# Print JSON-formatted suggestion payload
581587
waza suggest skills/code-explainer --format json
582588
```
583589

590+
Dry-run output includes per-task `confidence` and `rationale` fields so authors can review why each case was proposed. Apply mode validates generated eval and task YAML against the built-in schemas before writing, merges task references into an existing `eval.yaml`, and refuses to overwrite an existing fixture, task file, or task `id` unless `--force` is set.
591+
584592
### `waza tokens count [paths...]`
585593

586594
Count tokens in markdown files. Paths may be files or directories (scanned recursively for `.md`/`.mdx`).

cmd/waza/cmd_suggest.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ type suggestFlags struct {
2525
model string
2626
dryRun bool
2727
apply bool
28+
force bool
2829
outputDir string
2930
format string
31+
count int
32+
focus string
3033
}
3134

3235
func newSuggestCommand() *cobra.Command {
@@ -35,6 +38,7 @@ func newSuggestCommand() *cobra.Command {
3538
model: defaultModel,
3639
dryRun: true,
3740
format: "yaml",
41+
count: suggestpkg.DefaultCaseCount,
3842
}
3943

4044
cmd := &cobra.Command{
@@ -55,8 +59,11 @@ reviewed by a human before applying. By default, suggestions are printed to stdo
5559
cmd.Flags().StringVar(&flags.model, "model", flags.model, "Model to use for suggestions")
5660
cmd.Flags().BoolVar(&flags.dryRun, "dry-run", true, "Print suggestions to stdout without writing files")
5761
cmd.Flags().BoolVar(&flags.apply, "apply", false, "Write suggested files to disk")
62+
cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite conflicting generated files and task ids when applying")
5863
cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", "Directory for output (default: <skill-path>/evals)")
5964
cmd.Flags().StringVar(&flags.format, "format", "yaml", "Output format: yaml|json")
65+
cmd.Flags().IntVar(&flags.count, "count", suggestpkg.DefaultCaseCount, "Number of task cases to propose")
66+
cmd.Flags().StringVar(&flags.focus, "focus", "", fmt.Sprintf("Focus category: %s", strings.Join(suggestFocusValues(), "|")))
6067

6168
return cmd
6269
}
@@ -65,6 +72,13 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
6572
if flags.format != "yaml" && flags.format != "json" {
6673
return fmt.Errorf("invalid format %q: must be yaml or json", flags.format)
6774
}
75+
if flags.count < 1 {
76+
return fmt.Errorf("invalid count %d: must be at least 1", flags.count)
77+
}
78+
focus, err := suggestpkg.ParseFocusCategory(flags.focus)
79+
if err != nil {
80+
return err
81+
}
6882
if flags.apply {
6983
flags.dryRun = false
7084
}
@@ -93,6 +107,8 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
93107
suggestion, err := suggestpkg.Generate(cmd.Context(), engine, suggestpkg.Options{
94108
SkillPath: skillPath,
95109
GraderDocs: waza.GraderDocsFS,
110+
Count: flags.count,
111+
Focus: focus,
96112
})
97113
if err != nil {
98114
return err
@@ -110,7 +126,7 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
110126
return nil
111127
}
112128

113-
written, err := suggestion.WriteToDir(outputDir)
129+
written, err := suggestion.WriteToDirWithOptions(outputDir, suggestpkg.WriteOptions{Force: flags.force})
114130
if err != nil {
115131
return err
116132
}
@@ -134,6 +150,15 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
134150
return nil
135151
}
136152

153+
func suggestFocusValues() []string {
154+
categories := suggestpkg.AllFocusCategories()
155+
values := make([]string, 0, len(categories))
156+
for _, category := range categories {
157+
values = append(values, string(category))
158+
}
159+
return values
160+
}
161+
137162
func defaultSuggestOutputDir(skillPath string) string {
138163
candidate := skillPath
139164
if strings.EqualFold(filepath.Base(skillPath), "SKILL.md") {

cmd/waza/cmd_suggest_test.go

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,25 @@ type suggestTestEngine struct {
1818
err error
1919
callIdx int
2020
initCalled bool
21+
requests []string
2122
}
2223

2324
func (e *suggestTestEngine) Initialize(context.Context) error {
2425
e.initCalled = true
2526
return nil
2627
}
2728

28-
func (e *suggestTestEngine) Execute(_ context.Context, _ *execution.ExecutionRequest) (*execution.ExecutionResponse, error) {
29+
func (e *suggestTestEngine) Execute(_ context.Context, req *execution.ExecutionRequest) (*execution.ExecutionResponse, error) {
2930
if !e.initCalled {
3031
return nil, fmt.Errorf("initialize was not called before Execute!")
3132
}
3233

3334
if e.err != nil {
3435
return nil, e.err
3536
}
37+
if req != nil {
38+
e.requests = append(e.requests, req.Message)
39+
}
3640
idx := e.callIdx
3741
e.callIdx++
3842
if idx < len(e.outputs) {
@@ -90,6 +94,8 @@ func TestSuggestCommand_DryRunYAML(t *testing.T) {
9094
- "tasks/*.yaml"
9195
tasks:
9296
- path: tasks/basic.yaml
97+
confidence: 0.9
98+
rationale: "SKILL.md overview"
9399
content: |
94100
id: basic-001
95101
name: Basic
@@ -117,6 +123,65 @@ tasks:
117123
require.NoFileExists(t, filepath.Join(skillDir, "evals", "eval.yaml"))
118124
}
119125

126+
func TestSuggestCommand_CountFocusAndCaseMetadata(t *testing.T) {
127+
skillDir := writeSuggestSkill(t)
128+
engineOutput := `eval_yaml: |
129+
name: generated-eval
130+
description: generated
131+
skill: suggest-skill
132+
version: "1.0"
133+
config:
134+
trials_per_task: 1
135+
timeout_seconds: 120
136+
parallel: false
137+
executor: mock
138+
model: test
139+
graders:
140+
- type: text
141+
name: has_text
142+
config:
143+
contains:
144+
- hello
145+
metrics:
146+
- name: completion
147+
weight: 1.0
148+
threshold: 0.8
149+
tasks:
150+
- "tasks/*.yaml"
151+
tasks:
152+
- path: tasks/params.yaml
153+
confidence: 0.77
154+
rationale: "SKILL.md parameters section"
155+
content: |
156+
id: params-001
157+
name: Parameters
158+
inputs:
159+
prompt: "hello"
160+
`
161+
162+
selectionOutput := "graders:\n - text\n"
163+
engine := &suggestTestEngine{outputs: []string{selectionOutput, engineOutput}}
164+
165+
orig := newSuggestEngine
166+
newSuggestEngine = func(string) execution.AgentEngine {
167+
return engine
168+
}
169+
t.Cleanup(func() { newSuggestEngine = orig })
170+
171+
cmd := newSuggestCommand()
172+
var out bytes.Buffer
173+
cmd.SetOut(&out)
174+
cmd.SetErr(&out)
175+
cmd.SetArgs([]string{skillDir, "--count", "1", "--focus", "parameters"})
176+
177+
require.NoError(t, cmd.Execute())
178+
require.Contains(t, out.String(), "confidence: 0.77")
179+
require.Contains(t, out.String(), "rationale: SKILL.md parameters section")
180+
require.Len(t, engine.requests, 2)
181+
require.Contains(t, engine.requests[1], "Generate exactly 1 task case(s).")
182+
require.Contains(t, engine.requests[1], "parameter extraction")
183+
}
184+
120185
func TestSuggestCommand_ApplyWritesFiles(t *testing.T) {
121186
skillDir := writeSuggestSkill(t)
122187
engineOutput := `eval_yaml: |
@@ -144,6 +209,8 @@ func TestSuggestCommand_ApplyWritesFiles(t *testing.T) {
144209
- "tasks/*.yaml"
145210
tasks:
146211
- path: tasks/basic.yaml
212+
confidence: 0.9
213+
rationale: "SKILL.md overview"
147214
content: |
148215
id: basic-001
149216
name: Basic

internal/suggest/prompt.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,16 @@ type promptData struct {
6868
ContentSummary string
6969
GraderTypes string
7070
SkillContent string
71+
Count int
72+
Focus string
7173
}
7274

7375
// renderSelectionPrompt builds the pass-1 prompt that asks the LLM
7476
// to choose appropriate grader types for the skill.
7577
func renderSelectionPrompt(data promptData) string {
7678
var b strings.Builder
79+
count := effectivePromptCount(data.Count)
80+
focus := effectivePromptFocus(data.Focus)
7781
b.WriteString("You are selecting grader types for a waza evaluation suite.\n")
7882
b.WriteString("Given the skill description below, choose which grader types are most appropriate.\n\n")
7983
b.WriteString("Return ONLY a YAML list of grader type names, one per line, like:\n")
@@ -86,6 +90,8 @@ func renderSelectionPrompt(data promptData) string {
8690
fmt.Fprintf(&b, "- Triggers (USE FOR): %s\n", data.Triggers)
8791
fmt.Fprintf(&b, "- Anti-triggers (DO NOT USE FOR): %s\n", data.AntiTriggers)
8892
fmt.Fprintf(&b, "- Content summary: %s\n\n", data.ContentSummary)
93+
fmt.Fprintf(&b, "Requested case count: %d\n", count)
94+
fmt.Fprintf(&b, "Requested focus: %s\n\n", focus)
8995
b.WriteString("Available grader types:\n")
9096
b.WriteString(GraderSummaries())
9197
b.WriteString("\n")
@@ -96,12 +102,16 @@ func renderSelectionPrompt(data promptData) string {
96102
// the full eval YAML, with detailed docs for the selected grader types.
97103
func renderImplementationPrompt(data promptData, graderDocs string) string {
98104
var b strings.Builder
105+
count := effectivePromptCount(data.Count)
106+
focus := effectivePromptFocus(data.Focus)
99107
b.WriteString("You are generating a waza evaluation suite for a skill.\n")
100108
b.WriteString("Return ONLY YAML in this exact schema:\n\n")
101109
b.WriteString("eval_yaml: |\n")
102110
b.WriteString(" <full eval.yaml content>\n")
103111
b.WriteString("tasks:\n")
104112
b.WriteString(" - path: tasks/<task-file>.yaml\n")
113+
b.WriteString(" confidence: <0.0-1.0>\n")
114+
b.WriteString(" rationale: <SKILL.md span or phrase that supports this case>\n")
105115
b.WriteString(" content: |\n")
106116
b.WriteString(" <task yaml>\n")
107117
b.WriteString("fixtures:\n")
@@ -113,7 +123,10 @@ func renderImplementationPrompt(data promptData, graderDocs string) string {
113123
b.WriteString("- Each grader entry MUST be an object with at least 'type' and 'name' fields. NEVER use bare strings like '- grader_name'. Always use '- name: grader_name' with a 'type' field.\n")
114124
b.WriteString("- Include required config fields for each grader type (see grader documentation below).\n")
115125
b.WriteString("- For skill_invocation graders, use required_skills + mode (exact_match, in_order, any_order) for positive checks, forbidden_skills for negative checks, or both together.\n")
116-
b.WriteString("- Include at least 3 diverse tasks and at least 1 negative/anti-trigger task.\n")
126+
b.WriteString("- Make the task set diverse when count allows; include negative/anti-trigger coverage when it fits the requested focus.\n")
127+
fmt.Fprintf(&b, "- Generate exactly %d task case(s).\n", count)
128+
fmt.Fprintf(&b, "- Focus guidance: %s\n", focus)
129+
b.WriteString("- For every task case, include confidence as a number from 0.0 to 1.0 and rationale referencing the SKILL.md heading, phrase, or span that supports the case.\n")
117130
b.WriteString("- Use grader types from the allowed list only.\n")
118131
b.WriteString("- Keep task IDs deterministic and kebab-case.\n")
119132
b.WriteString("- Task YAML must use inputs: { prompt: ... } (do not use a top-level prompt field).\n")
@@ -135,12 +148,27 @@ func renderImplementationPrompt(data promptData, graderDocs string) string {
135148
b.WriteString(graderDocs)
136149
b.WriteString("\n\n")
137150
}
151+
138152
b.WriteString("Skill content (SKILL.md):\n")
139153
b.WriteString(data.SkillContent)
140154
b.WriteString("\n")
141155
return b.String()
142156
}
143157

158+
func effectivePromptCount(count int) int {
159+
if count <= 0 {
160+
return DefaultCaseCount
161+
}
162+
return count
163+
}
164+
165+
func effectivePromptFocus(focus string) string {
166+
if strings.TrimSpace(focus) == "" {
167+
return focusInstruction("")
168+
}
169+
return focus
170+
}
171+
144172
// renderPrompt builds a single-pass prompt (used when no grader docs FS is available).
145173
func renderPrompt(data promptData) string {
146174
return renderImplementationPrompt(data, "")

internal/suggest/prompt_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ func TestRenderImplementationPrompt_ContainsRequirements(t *testing.T) {
7373
assert.Contains(t, prompt, "NEVER use bare strings")
7474
assert.Contains(t, prompt, "required_skills")
7575
assert.Contains(t, prompt, "Task YAML must use inputs")
76-
assert.Contains(t, prompt, "at least 3 diverse tasks")
77-
assert.Contains(t, prompt, "at least 1 negative/anti-trigger task")
76+
assert.Contains(t, prompt, "Generate exactly 3 task case(s)")
77+
assert.Contains(t, prompt, "confidence")
78+
assert.Contains(t, prompt, "rationale")
7879
}
7980

8081
func TestRenderPrompt_IsSinglePassFallback(t *testing.T) {

internal/suggest/resolve_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func TestBuildPromptData_ExtractsFields(t *testing.T) {
112112
rawContent, sk, err := loadSkill(skillPath)
113113
require.NoError(t, err)
114114

115-
data := buildPromptData(sk, rawContent)
115+
data := buildPromptData(sk, rawContent, DefaultCaseCount, "")
116116
assert.Equal(t, "build-prompt-skill", data.SkillName)
117117
assert.NotEmpty(t, data.Description)
118118
assert.Contains(t, data.Triggers, "summarize")
@@ -133,7 +133,7 @@ func TestBuildPromptData_FallbackSkillName(t *testing.T) {
133133
rawContent, sk, err := loadSkill(skillPath)
134134
require.NoError(t, err)
135135

136-
data := buildPromptData(sk, rawContent)
136+
data := buildPromptData(sk, rawContent, DefaultCaseCount, "")
137137
// Name not set in frontmatter, should fall back to parent dir name
138138
assert.Equal(t, "my-cool-skill", data.SkillName)
139139
}
@@ -147,7 +147,7 @@ func TestBuildPromptData_NoTriggers(t *testing.T) {
147147
rawContent, sk, err := loadSkill(skillPath)
148148
require.NoError(t, err)
149149

150-
data := buildPromptData(sk, rawContent)
150+
data := buildPromptData(sk, rawContent, DefaultCaseCount, "")
151151
assert.Equal(t, "none", data.Triggers)
152152
assert.Equal(t, "none", data.AntiTriggers)
153153
}
@@ -158,7 +158,7 @@ func TestWriteToDir_EmptyTaskPath(t *testing.T) {
158158
s := &Suggestion{
159159
EvalYAML: validEvalYAML(),
160160
Tasks: []GeneratedFile{
161-
{Path: "", Content: "id: auto\nname: Auto\ninputs:\n prompt: hi"},
161+
{Path: "", Confidence: testConfidence(0.8), Rationale: "SKILL.md overview", Content: "id: auto\nname: Auto\ninputs:\n prompt: hi"},
162162
},
163163
}
164164

@@ -190,7 +190,7 @@ func TestWriteToDir_AbsolutePathRejected(t *testing.T) {
190190
s := &Suggestion{
191191
EvalYAML: validEvalYAML(),
192192
Tasks: []GeneratedFile{
193-
{Path: absPath, Content: "id: x\nname: X\ninputs:\n prompt: hi"},
193+
{Path: absPath, Confidence: testConfidence(0.8), Rationale: "SKILL.md overview", Content: "id: x\nname: X\ninputs:\n prompt: hi"},
194194
},
195195
}
196196

0 commit comments

Comments
 (0)