Skip to content

Commit e3b6d9e

Browse files
committed
feat(operator): harden AI suggester prompt with worked examples
Rewrite the rule-suggester system prompt around the actual single-rule output shape (llmSuggestedRule): one transform_type, one rule per request. The previous prompt described the types accurately but gave the model no concrete examples to anchor the decision between move / copy / glob / regex. New prompt adds: - Explicit response-shape template with field-level "for move or copy" / "for glob or regex" guidance. - Default rules for destination_branch ("main") and commit_strategy ("pull_request" unless clearly a direct commit). - Four worked Input/Output examples — one per transform type — using realistic paths (mflix-java-spring prefix rename, mflix README copy, agg/python glob with ${relative_path}, tutorials/v2 regex with named captures). Also exports the prompt as services.SuggestRuleSystemPrompt and wires it into cmd/test-llm so the smoke test exercises the exact prompt writers hit via the UI. Verified end-to-end against the Grove Foundry APIM gateway: claude-haiku-4-5 now correctly picks "glob" with ${relative_path} for a .py-in-directory example (previously chose "move" with the exact file path).
1 parent 31e27e5 commit e3b6d9e

2 files changed

Lines changed: 54 additions & 24 deletions

File tree

cmd/test-llm/main.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,18 @@ func main() {
8383
}
8484
}
8585

86-
// 3. GenerateJSON with the real rule-suggester system prompt.
87-
systemPrompt := `You are an expert in GitHub Copier workflow configuration. Given a source→target file transformation example, return a JSON object with fields: transform_type ("move"|"copy"|"glob"|"regex"), transform_from, transform_to, pattern, transform_template, name, destination_repo, destination_branch, commit_strategy, explanation. Prefer the simplest transform type that works.`
88-
userPrompt := `Source file: agg/python/models/user.py
86+
// 3. GenerateJSON using the real rule-suggester system prompt. Importing
87+
// services.SuggestRuleSystemPrompt keeps the smoke test in lock-step with
88+
// what writers hit via the UI — if the prompt changes, the smoke test
89+
// covers the new behavior automatically.
90+
systemPrompt := services.SuggestRuleSystemPrompt
91+
userPrompt := `Generate a copier rule for this transformation:
92+
93+
Source file: agg/python/models/user.py
8994
Target file: shared/python/models/user.py
9095
Target repo: org/shared-examples
9196
92-
Return ONLY a JSON object.`
97+
Return ONLY a JSON object with the fields documented above. No prose outside the JSON.`
9398

9499
ctx, cancel = context.WithTimeout(context.Background(), *timeout)
95100
raw, err := client.GenerateJSON(ctx, systemPrompt, userPrompt)

services/operator_suggest_rule.go

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -116,30 +116,55 @@ func (o *operatorUI) handleSuggestRule(w http.ResponseWriter, r *http.Request) {
116116
_ = json.NewEncoder(w).Encode(resp)
117117
}
118118

119-
// askLLMForRule sends a structured prompt to the LLM and parses the JSON response.
120-
func (o *operatorUI) askLLMForRule(ctx context.Context, req operatorSuggestRuleRequest) (*llmSuggestedRule, error) {
121-
systemPrompt := `You are an expert in GitHub Copier workflow configuration. You generate concise, correct YAML rules that match a single source→target file transformation example.
119+
// SuggestRuleSystemPrompt is the system prompt used by the AI rule suggester.
120+
// Exported so cmd/test-llm can exercise the real prompt end-to-end against
121+
// the configured provider (same prompt writers will hit via the UI).
122+
const SuggestRuleSystemPrompt = `You are a configuration generator for GitHub Copier workflows.
123+
124+
Given a single source→target file transformation example, output ONLY a valid JSON object — no markdown, no prose outside the JSON. Generate ONE rule describing ONE transformation.
125+
126+
Transform types (prefer the simplest that works — move > copy > glob > regex):
127+
- "move" — rename a directory prefix. Matches any file under transform_from; replaces the prefix with transform_to. Use when the source and target share the subpath below the renamed prefix.
128+
- "copy" — rename ONE exact file. Use when the example is a specific file pair, not a pattern.
129+
- "glob" — wildcards in pattern (e.g. "dir/**/*.ext"). Use "${relative_path}" in transform_template to preserve subdir structure after the matched prefix.
130+
- "regex" — Go RE2 regex with named captures (e.g. "(?P<name>.+)"). Use ONLY when move/copy/glob cannot express the rename.
131+
132+
Response shape (omit fields that don't apply to the chosen transform_type):
133+
{
134+
"name": "kebab-case-rule-name",
135+
"destination_repo": "org/dest-repo",
136+
"destination_branch": "main",
137+
"commit_strategy": "pull_request",
138+
"transform_type": "move" | "copy" | "glob" | "regex",
139+
"transform_from": "<for move or copy>",
140+
"transform_to": "<for move or copy>",
141+
"pattern": "<for glob or regex>",
142+
"transform_template": "<for glob or regex>",
143+
"explanation": "one sentence describing what this rule does"
144+
}
145+
146+
Rules:
147+
- destination_branch defaults to "main"; commit_strategy defaults to "pull_request" (use "direct" only if the user's intent is clearly a direct commit).
148+
- If the user did not provide a target repo, use a placeholder like "org/target-repo" so the writer can fill it in.
149+
- name should be short and kebab-case, derived from the source directory or file.
150+
- The rule MUST produce the user's target path when applied to their source path. Verify the logic before responding.
151+
152+
Examples
122153
123-
The copier supports 4 transform types (pick the simplest that works):
124-
- move: { from: "prefix/path", to: "new/prefix" } — renames a directory prefix. Matches any file under "from" and replaces the prefix with "to".
125-
- copy: { from: "exact/file.md", to: "new/file.md" } — renames one exact file. Use only when source is a single specific file.
126-
- glob: { pattern: "dir/**/*.ext", transform: "new/${relative_path}" } — matches files by glob pattern. Use "${relative_path}" to preserve subdirectory structure.
127-
- regex: { pattern: "dir/(?P<name>.+)\\.ext", transform: "new/${name}.ext" } — uses Go regex with named capture groups. Use ONLY when move/copy/glob are insufficient.
154+
Input: source=mflix/server/java-spring/App.java target=server/App.java repo=mongodb/sample-app-java-mflix
155+
Output: {"name":"mflix-java-spring-server","destination_repo":"mongodb/sample-app-java-mflix","destination_branch":"main","commit_strategy":"pull_request","transform_type":"move","transform_from":"mflix/server/java-spring","transform_to":"server","explanation":"Renames the mflix/server/java-spring prefix to server when copying into the target repo."}
128156
129-
Prefer move > copy > glob > regex (simpler is better).
157+
Input: source=mflix/README-JAVA-SPRING.md target=README.md repo=mongodb/sample-app-java-mflix
158+
Output: {"name":"mflix-readme","destination_repo":"mongodb/sample-app-java-mflix","destination_branch":"main","commit_strategy":"pull_request","transform_type":"copy","transform_from":"mflix/README-JAVA-SPRING.md","transform_to":"README.md","explanation":"Copies one specific README file and renames it in the destination."}
130159
131-
You return JSON with these fields:
132-
- transform_type: "move" | "copy" | "glob" | "regex"
133-
- transform_from, transform_to: for move/copy
134-
- pattern, transform_template: for glob/regex
135-
- name: a kebab-case rule name (e.g., "agg-python-models")
136-
- destination_repo: the target repository (use the one the user provided, or infer from context)
137-
- destination_branch: optional, defaults to "main"
138-
- commit_strategy: "direct" or "pull_request" (default "pull_request")
139-
- explanation: 1-2 sentence plain-English justification
160+
Input: source=agg/python/models/user.py target=shared/python/models/user.py repo=org/shared-examples
161+
Output: {"name":"agg-python","destination_repo":"org/shared-examples","destination_branch":"main","commit_strategy":"pull_request","transform_type":"glob","pattern":"agg/python/**/*.py","transform_template":"shared/python/${relative_path}","explanation":"Matches any .py file under agg/python and preserves the subdirectory structure under shared/python."}
140162
141-
IMPORTANT: The generated rule MUST produce the user's target file when applied to their source file. Test your logic mentally before responding.`
163+
Input: source=tutorials/v2/getting-started.mdx target=docs/getting-started-v2.mdx repo=org/docs-site
164+
Output: {"name":"tutorials-versioned","destination_repo":"org/docs-site","destination_branch":"main","commit_strategy":"pull_request","transform_type":"regex","pattern":"tutorials/v(?P<ver>[0-9]+)/(?P<slug>.+)\\.mdx","transform_template":"docs/${slug}-v${ver}.mdx","explanation":"Extracts version and slug from the source path and rebuilds the target filename with the version as a suffix."}`
142165

166+
// askLLMForRule sends a structured prompt to the LLM and parses the JSON response.
167+
func (o *operatorUI) askLLMForRule(ctx context.Context, req operatorSuggestRuleRequest) (*llmSuggestedRule, error) {
143168
userPrompt := fmt.Sprintf(`Generate a copier rule for this transformation:
144169
145170
Source file: %s
@@ -149,7 +174,7 @@ Target repo: %s
149174
Return ONLY a JSON object with the fields documented above. No prose outside the JSON.`,
150175
req.SourcePath, req.TargetPath, defaultIfEmpty(req.TargetRepo, "(user did not specify — use a placeholder like \"org/target-repo\")"))
151176

152-
raw, err := o.llm.GenerateJSON(ctx, systemPrompt, userPrompt)
177+
raw, err := o.llm.GenerateJSON(ctx, SuggestRuleSystemPrompt, userPrompt)
153178
if err != nil {
154179
return nil, fmt.Errorf("LLM error: %w", err)
155180
}

0 commit comments

Comments
 (0)