Skip to content

Commit a29cdc1

Browse files
committed
feat: synthetic test case generation flags for waza suggest (#357)
Extend `waza suggest` with steerable generation and merge-safe apply: - New flags: --count, --focus, --force (in addition to existing --dry-run / --apply / --model / --output-dir / --format) - Per-case confidence (0-1) and rationale referencing the SKILL.md span - --apply is merge-safe: existing eval.yaml is preserved (new task files picked up via existing tasks: glob), and existing task / fixture files (by path or task id) are never overwritten unless --force is also passed - Generated tasks are schema-validated via internal/validation before being written to disk - Focus categories: triggers, negative-triggers, edge-fixtures, do-not-use-for, parameters - Tests cover focus directives, count guidance, overwrite safety (with and without --force), duplicate task id detection (in-batch and against existing tasks), and schema validation failures - README.md and site CLI reference updated with new flags + worked examples Closes #357 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5af9038 commit a29cdc1

8 files changed

Lines changed: 485 additions & 24 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,17 +566,30 @@ Use an LLM to analyze `SKILL.md` and generate suggested evaluation artifacts.
566566
| `--model <model>` | Model to use for suggestions (default: project default model) |
567567
| `--dry-run` | Print suggested output to stdout (default) |
568568
| `--apply` | Write files to disk |
569+
| `--force` | Allow `--apply` to overwrite existing eval/task/fixture files (requires `--apply`) |
570+
| `--count <n>` | Generate exactly N tasks (default: at least 3 + 1 negative) |
571+
| `--focus <category>` | Steer generation toward one of `triggers`, `negative-triggers`, `edge-fixtures`, `do-not-use-for`, `parameters` |
569572
| `--output-dir <dir>` | Output directory (default: `<skill-path>/evals`) |
570573
| `--format yaml\|json` | Output format (default: `yaml`) |
571574

575+
Each generated task entry carries a `confidence` score in `[0, 1]` and a `rationale` string pointing to the SKILL.md span it was derived from. Both appear in dry-run output but are kept outside the written task YAML (the task schema rejects unknown fields).
576+
577+
`--apply` is merge-safe: an existing `eval.yaml` is never overwritten (new task files are picked up by its existing `tasks:` glob), and existing task / fixture files (by path or by task `id`) cause `--apply` to fail unless `--force` is also passed.
578+
572579
**Examples:**
573580
```bash
574581
# Preview generated eval/task/fixture files as YAML
575582
waza suggest skills/code-explainer --dry-run
576583

584+
# Generate exactly 5 negative-trigger tasks and merge them into the existing suite
585+
waza suggest skills/code-explainer --focus negative-triggers --count 5 --apply
586+
577587
# Write generated files to disk
578588
waza suggest skills/code-explainer --apply
579589

590+
# Overwrite a previously generated suite
591+
waza suggest skills/code-explainer --apply --force
592+
580593
# Print JSON-formatted suggestion payload
581594
waza suggest skills/code-explainer --format json
582595
```

cmd/waza/cmd_suggest.go

Lines changed: 24 additions & 2 deletions
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 {
@@ -44,7 +47,12 @@ func newSuggestCommand() *cobra.Command {
4447
4548
This command is experimental. Because an LLM generates suggestions, they should be
4649
reviewed by a human before applying. By default, suggestions are printed to stdout
47-
(--dry-run). Use --apply to write suggested eval.yaml, tasks, and fixtures to disk.`,
50+
(--dry-run). Use --apply to write suggested eval.yaml, tasks, and fixtures to disk.
51+
52+
Use --count to control how many cases are proposed, --focus to steer generation
53+
toward a category (` + strings.Join(suggestpkg.AvailableFocusCategories(), ", ") + `),
54+
and --force to overwrite existing files (default is merge-safe: existing eval.yaml
55+
and task ids are preserved).`,
4856
Args: cobra.ExactArgs(1),
4957
RunE: func(cmd *cobra.Command, args []string) error {
5058
return runSuggestCommand(cmd, args[0], flags)
@@ -55,8 +63,11 @@ reviewed by a human before applying. By default, suggestions are printed to stdo
5563
cmd.Flags().StringVar(&flags.model, "model", flags.model, "Model to use for suggestions")
5664
cmd.Flags().BoolVar(&flags.dryRun, "dry-run", true, "Print suggestions to stdout without writing files")
5765
cmd.Flags().BoolVar(&flags.apply, "apply", false, "Write suggested files to disk")
66+
cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite existing files and duplicate task ids (use with --apply)")
5867
cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", "Directory for output (default: <skill-path>/evals)")
5968
cmd.Flags().StringVar(&flags.format, "format", "yaml", "Output format: yaml|json")
69+
cmd.Flags().IntVar(&flags.count, "count", 0, "Number of test cases to propose (0 = model default)")
70+
cmd.Flags().StringVar(&flags.focus, "focus", "", "Steer generation toward a category: "+strings.Join(suggestpkg.AvailableFocusCategories(), "|"))
6071

6172
return cmd
6273
}
@@ -65,12 +76,21 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
6576
if flags.format != "yaml" && flags.format != "json" {
6677
return fmt.Errorf("invalid format %q: must be yaml or json", flags.format)
6778
}
79+
if err := suggestpkg.ValidateFocus(flags.focus); err != nil {
80+
return err
81+
}
82+
if flags.count < 0 {
83+
return fmt.Errorf("invalid --count %d: must be >= 0", flags.count)
84+
}
6885
if flags.apply {
6986
flags.dryRun = false
7087
}
7188
if !flags.apply && !flags.dryRun {
7289
return errors.New("either --dry-run or --apply must be enabled")
7390
}
91+
if flags.force && !flags.apply {
92+
return errors.New("--force requires --apply")
93+
}
7494

7595
outputDir := flags.outputDir
7696
if outputDir == "" {
@@ -93,6 +113,8 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
93113
suggestion, err := suggestpkg.Generate(cmd.Context(), engine, suggestpkg.Options{
94114
SkillPath: skillPath,
95115
GraderDocs: waza.GraderDocsFS,
116+
Count: flags.count,
117+
Focus: flags.focus,
96118
})
97119
if err != nil {
98120
return err
@@ -110,7 +132,7 @@ func runSuggestCommand(cmd *cobra.Command, skillPath string, flags *suggestFlags
110132
return nil
111133
}
112134

113-
written, err := suggestion.WriteToDir(outputDir)
135+
written, err := suggestion.WriteToDir(outputDir, suggestpkg.WriteOptions{Force: flags.force})
114136
if err != nil {
115137
return err
116138
}

internal/suggest/prompt.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,41 @@ type promptData struct {
6868
ContentSummary string
6969
GraderTypes string
7070
SkillContent string
71+
// Count is the desired number of test cases (<= 0 means use default).
72+
Count int
73+
// Focus is an optional focus category that steers the kinds of cases
74+
// the LLM should emit (e.g. "negative-triggers"). Empty means balanced.
75+
Focus string
76+
}
77+
78+
// focusDirective returns prompt text that steers generation toward a focus
79+
// category. Returns "" when focus is empty.
80+
func focusDirective(focus string) string {
81+
switch FocusCategory(focus) {
82+
case "":
83+
return ""
84+
case FocusTriggers:
85+
return "FOCUS: Generate tasks that exercise the skill's positive trigger phrases " +
86+
"(the 'USE FOR' list). Each task should match a distinct trigger so we can verify " +
87+
"the skill activates when it should."
88+
case FocusNegativeTriggers:
89+
return "FOCUS: Generate tasks that look superficially like triggers but should NOT " +
90+
"invoke this skill. Use skill_invocation graders with forbidden_skills to assert " +
91+
"the skill stays out of these cases."
92+
case FocusEdgeFixtures:
93+
return "FOCUS: Generate tasks that stress edge cases in the input fixtures — empty " +
94+
"files, very large files, malformed inputs, unicode, ambiguous content. Each task " +
95+
"should carry a realistic edge-case fixture under fixtures/."
96+
case FocusDoNotUseFor:
97+
return "FOCUS: Generate tasks drawn from the skill's 'DO NOT USE FOR' anti-trigger " +
98+
"list. Each task must assert the skill refuses or routes elsewhere (use " +
99+
"skill_invocation forbidden_skills, or text graders that check for refusal language)."
100+
case FocusParameters:
101+
return "FOCUS: Generate tasks that vary the parameters and tool arguments the skill " +
102+
"would supply — required vs optional, valid vs invalid values, boundary numerics. " +
103+
"Use tool_constraint or action_sequence graders where appropriate."
104+
}
105+
return ""
71106
}
72107

73108
// renderSelectionPrompt builds the pass-1 prompt that asks the LLM
@@ -102,6 +137,8 @@ func renderImplementationPrompt(data promptData, graderDocs string) string {
102137
b.WriteString(" <full eval.yaml content>\n")
103138
b.WriteString("tasks:\n")
104139
b.WriteString(" - path: tasks/<task-file>.yaml\n")
140+
b.WriteString(" confidence: 0.0-1.0\n")
141+
b.WriteString(" rationale: <one sentence pointing to the SKILL.md span this case came from>\n")
105142
b.WriteString(" content: |\n")
106143
b.WriteString(" <task yaml>\n")
107144
b.WriteString("fixtures:\n")
@@ -113,11 +150,21 @@ func renderImplementationPrompt(data promptData, graderDocs string) string {
113150
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")
114151
b.WriteString("- Include required config fields for each grader type (see grader documentation below).\n")
115152
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")
153+
if data.Count > 0 {
154+
fmt.Fprintf(&b, "- Generate EXACTLY %d tasks. Do not generate more or fewer.\n", data.Count)
155+
} else {
156+
b.WriteString("- Include at least 3 diverse tasks and at least 1 negative/anti-trigger task.\n")
157+
}
117158
b.WriteString("- Use grader types from the allowed list only.\n")
118159
b.WriteString("- Keep task IDs deterministic and kebab-case.\n")
119160
b.WriteString("- Task YAML must use inputs: { prompt: ... } (do not use a top-level prompt field).\n")
120-
b.WriteString("- Make fixtures minimal and realistic for the tasks.\n\n")
161+
b.WriteString("- Task YAML must NOT include 'confidence' or 'rationale' inside the task content; those belong on the outer suggestion entry and will be stripped before writing.\n")
162+
b.WriteString("- Each task entry MUST carry a 'confidence' float in [0,1] and a 'rationale' string citing the SKILL.md span (e.g. \"matches USE FOR: summarize bullet 2\").\n")
163+
b.WriteString("- Make fixtures minimal and realistic for the tasks.\n")
164+
if directive := focusDirective(data.Focus); directive != "" {
165+
b.WriteString("- " + directive + "\n")
166+
}
167+
b.WriteString("\n")
121168
b.WriteString("Skill metadata:\n")
122169
fmt.Fprintf(&b, "- Name: %s\n", data.SkillName)
123170
fmt.Fprintf(&b, "- Description: %s\n", data.Description)

internal/suggest/resolve_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func TestWriteToDir_EmptyTaskPath(t *testing.T) {
163163
}
164164

165165
dir := t.TempDir()
166-
written, err := s.WriteToDir(dir)
166+
written, err := s.WriteToDir(dir, WriteOptions{})
167167
require.NoError(t, err)
168168
assert.Len(t, written, 2) // eval.yaml + auto-named task
169169
}
@@ -177,7 +177,7 @@ func TestWriteToDir_EmptyFixturePath(t *testing.T) {
177177
}
178178

179179
dir := t.TempDir()
180-
written, err := s.WriteToDir(dir)
180+
written, err := s.WriteToDir(dir, WriteOptions{})
181181
require.NoError(t, err)
182182
assert.Len(t, written, 2) // eval.yaml + auto-named fixture
183183
}
@@ -195,7 +195,7 @@ func TestWriteToDir_AbsolutePathRejected(t *testing.T) {
195195
}
196196

197197
dir := t.TempDir()
198-
_, err := s.WriteToDir(dir)
198+
_, err := s.WriteToDir(dir, WriteOptions{})
199199
require.Error(t, err)
200200
assert.Contains(t, err.Error(), "invalid generated path")
201201
}
@@ -209,7 +209,7 @@ func TestWriteToDir_TraversalPathRejected(t *testing.T) {
209209
}
210210

211211
dir := t.TempDir()
212-
_, err := s.WriteToDir(dir)
212+
_, err := s.WriteToDir(dir, WriteOptions{})
213213
require.Error(t, err)
214214
assert.Contains(t, err.Error(), "invalid generated path")
215215
}
@@ -220,7 +220,7 @@ func TestWriteToDir_InvalidEvalYAML(t *testing.T) {
220220
}
221221

222222
dir := t.TempDir()
223-
_, err := s.WriteToDir(dir)
223+
_, err := s.WriteToDir(dir, WriteOptions{})
224224
require.Error(t, err)
225225
}
226226

0 commit comments

Comments
 (0)