Allow prompt version selection without inputs
This commit is contained in:
@@ -32,21 +32,22 @@ const (
|
||||
type runConfig struct {
|
||||
configPath string
|
||||
|
||||
promptDir string
|
||||
profileDir string
|
||||
promptID string
|
||||
profileID string
|
||||
inputRaw listFlag
|
||||
varRaw listFlag
|
||||
outputPath string
|
||||
llmBaseURL string
|
||||
apiKeyEnv string
|
||||
model string
|
||||
temperature float64
|
||||
maxTokens int
|
||||
topP float64
|
||||
schemaDir string
|
||||
timeout time.Duration
|
||||
promptDir string
|
||||
profileDir string
|
||||
promptID string
|
||||
promptVersion string
|
||||
profileID string
|
||||
inputRaw listFlag
|
||||
varRaw listFlag
|
||||
outputPath string
|
||||
llmBaseURL string
|
||||
apiKeyEnv string
|
||||
model string
|
||||
temperature float64
|
||||
maxTokens int
|
||||
topP float64
|
||||
schemaDir string
|
||||
timeout time.Duration
|
||||
|
||||
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||
|
||||
@@ -349,6 +350,7 @@ func registerExecutionRequestFlags(fs *flag.FlagSet, cfg *runConfig) {
|
||||
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
|
||||
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
|
||||
fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to run")
|
||||
fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt definition version")
|
||||
fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used")
|
||||
fs.Var(&cfg.inputRaw, "input", "input mapping(s): name=path (repeatable, comma-separated)")
|
||||
fs.Var(&cfg.varRaw, "var", "variable mapping(s): name=value (repeatable, comma-separated)")
|
||||
@@ -389,9 +391,6 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
||||
if strings.TrimSpace(cfg.promptID) == "" {
|
||||
return errors.New("--prompt is required")
|
||||
}
|
||||
if len(cfg.inputRaw) == 0 {
|
||||
return errors.New("at least one --input is required")
|
||||
}
|
||||
cfg.promptDir = filepath.Clean(cfg.promptDir)
|
||||
if strings.TrimSpace(cfg.profileDir) != "" {
|
||||
cfg.profileDir = filepath.Clean(cfg.profileDir)
|
||||
@@ -547,9 +546,13 @@ func newEngine(cfg *runConfig, options ...promptkit.Option) (*promptkit.Engine,
|
||||
}
|
||||
|
||||
func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
|
||||
inputMappings, err := parseMappings(cfg.inputRaw, false)
|
||||
if err != nil {
|
||||
return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err)
|
||||
var inputMappings map[string]string
|
||||
var err error
|
||||
if len(cfg.inputRaw) > 0 {
|
||||
inputMappings, err = parseMappings(cfg.inputRaw, false)
|
||||
if err != nil {
|
||||
return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
varMappings := map[string]string{}
|
||||
@@ -560,9 +563,12 @@ func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
|
||||
}
|
||||
}
|
||||
|
||||
inputs := make(map[string]promptkit.ArtifactRef, len(inputMappings))
|
||||
for name, path := range inputMappings {
|
||||
inputs[name] = promptkit.File(path)
|
||||
var inputs map[string]promptkit.ArtifactRef
|
||||
if len(inputMappings) > 0 {
|
||||
inputs = make(map[string]promptkit.ArtifactRef, len(inputMappings))
|
||||
for name, path := range inputMappings {
|
||||
inputs[name] = promptkit.File(path)
|
||||
}
|
||||
}
|
||||
|
||||
var modelOverride *promptkit.ExecutionTargetOverride
|
||||
@@ -588,11 +594,12 @@ func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
|
||||
}
|
||||
|
||||
return promptkit.RunRequest{
|
||||
PromptID: cfg.promptID,
|
||||
ProfileID: cfg.profileID,
|
||||
Inputs: inputs,
|
||||
Vars: varMappings,
|
||||
Execution: modelOverride,
|
||||
PromptID: cfg.promptID,
|
||||
PromptVersion: cfg.promptVersion,
|
||||
ProfileID: cfg.profileID,
|
||||
Inputs: inputs,
|
||||
Vars: varMappings,
|
||||
Execution: modelOverride,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -687,7 +694,7 @@ func printSummary(stderr io.Writer, res *promptkit.RunResult) {
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage: scriptorium <run|render|serve> ...")
|
||||
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]")
|
||||
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--format text|json] [--out path] [--timeout 10m]")
|
||||
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]")
|
||||
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--format text|json] [--out path] [--timeout 10m]")
|
||||
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
|
||||
}
|
||||
|
||||
@@ -87,9 +87,12 @@ func TestParseRunArgsRequiredFlags(t *testing.T) {
|
||||
t.Fatal("expected missing --prompt error")
|
||||
}
|
||||
|
||||
_, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing --input error")
|
||||
cfg, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected omitted --input to be accepted, got %v", err)
|
||||
}
|
||||
if len(cfg.inputRaw) != 0 {
|
||||
t.Fatalf("expected no input mappings, got %#v", cfg.inputRaw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +101,7 @@ func TestParseRunArgsFlagMapping(t *testing.T) {
|
||||
"--prompt-dir", "./prompts",
|
||||
"--profile-dir", "./profiles",
|
||||
"--prompt", "prompt.a",
|
||||
"--prompt-version", "2",
|
||||
"--profile", "profile.a",
|
||||
"--input", "a=b",
|
||||
"--llm-base-url", "http://x/v1",
|
||||
@@ -114,8 +118,8 @@ func TestParseRunArgsFlagMapping(t *testing.T) {
|
||||
if cfg.promptDir != filepath.Clean("./prompts") || cfg.profileDir != filepath.Clean("./profiles") {
|
||||
t.Fatalf("unexpected dirs: prompt=%q profile=%q", cfg.promptDir, cfg.profileDir)
|
||||
}
|
||||
if cfg.promptID != "prompt.a" || cfg.profileID != "profile.a" {
|
||||
t.Fatalf("unexpected prompt/profile ids: %q %q", cfg.promptID, cfg.profileID)
|
||||
if cfg.promptID != "prompt.a" || cfg.promptVersion != "2" || cfg.profileID != "profile.a" {
|
||||
t.Fatalf("unexpected prompt/version/profile ids: %q %q %q", cfg.promptID, cfg.promptVersion, cfg.profileID)
|
||||
}
|
||||
if !cfg.llmBaseURLSet || !cfg.modelSet || !cfg.temperatureSet || !cfg.maxTokensSet || !cfg.topPSet || !cfg.timeoutSet || !cfg.apiKeyEnvSet {
|
||||
t.Fatalf("expected override flags set, got %+v", cfg)
|
||||
@@ -192,7 +196,7 @@ func TestParseServeArgsRejectsRuntimeOverrideFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageIncludesServeFileAndSizeLimitFlags(t *testing.T) {
|
||||
func TestUsageIncludesExecutionAndServeFlags(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
code := Run(nil, io.Discard, &stderr)
|
||||
if code != ExitRuntimeError {
|
||||
@@ -201,6 +205,7 @@ func TestUsageIncludesServeFileAndSizeLimitFlags(t *testing.T) {
|
||||
|
||||
usage := stderr.String()
|
||||
for _, want := range []string{
|
||||
"--prompt-version VERSION",
|
||||
"--artifact-root",
|
||||
"--max-request-bytes",
|
||||
"--max-artifact-bytes",
|
||||
@@ -689,6 +694,22 @@ func TestBuildRunRequestPreservesNumericOverridePresence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunRequestAllowsOmittedInputsAndMapsPromptVersion(t *testing.T) {
|
||||
req, err := buildRunRequestFromConfig(&runConfig{
|
||||
promptID: "prompt-1",
|
||||
promptVersion: "2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected request without inputs to build, got %v", err)
|
||||
}
|
||||
if req.PromptVersion != "2" {
|
||||
t.Fatalf("expected prompt version to be mapped, got %q", req.PromptVersion)
|
||||
}
|
||||
if req.Inputs != nil {
|
||||
t.Fatalf("expected omitted inputs to remain nil, got %#v", req.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) {
|
||||
configPath := writeAppConfigFile(t, `
|
||||
profile_dir: ./profiles
|
||||
@@ -1122,6 +1143,104 @@ func TestRenderCommandExplicitProfileOverridesPromptDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCommandUsesDefinitionInputRulesAndPromptVersions(t *testing.T) {
|
||||
lib := newCLITestLibrary(t)
|
||||
writeProfileFile(t, lib.profileDir, "local", "http://127.0.0.1:1/v1", "model")
|
||||
|
||||
writePromptDefinition(t, lib.promptDir, "sole.yaml", `id: sole
|
||||
version: "1"
|
||||
default_profile: local
|
||||
messages:
|
||||
- role: user
|
||||
content: "hello"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)
|
||||
writePromptDefinition(t, lib.promptDir, "versioned-one.yaml", `id: versioned
|
||||
version: "1"
|
||||
default_profile: local
|
||||
messages:
|
||||
- role: user
|
||||
content: "one"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)
|
||||
writePromptDefinition(t, lib.promptDir, "versioned-two.yaml", `id: versioned
|
||||
version: "2"
|
||||
default_profile: local
|
||||
messages:
|
||||
- role: user
|
||||
content: "two"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)
|
||||
writePromptDefinition(t, lib.promptDir, "optional.yaml", `id: optional
|
||||
version: "1"
|
||||
default_profile: local
|
||||
inputs:
|
||||
- name: note
|
||||
required: false
|
||||
messages:
|
||||
- role: user
|
||||
content: "hello"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)
|
||||
writePromptDefinition(t, lib.promptDir, "required.yaml", `id: required
|
||||
version: "1"
|
||||
default_profile: local
|
||||
inputs:
|
||||
- name: note
|
||||
required: true
|
||||
messages:
|
||||
- role: user
|
||||
content: "hello"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)
|
||||
writePromptDefinition(t, lib.promptDir, "template.yaml", `id: template
|
||||
version: "1"
|
||||
default_profile: local
|
||||
messages:
|
||||
- role: user
|
||||
content: '{{input "note"}}'
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
`)
|
||||
|
||||
baseArgs := []string{"--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCode int
|
||||
wantText string
|
||||
}{
|
||||
{name: "sole version selected when omitted", args: []string{"--prompt", "sole"}, wantCode: ExitOK, wantText: "prompt_version: 1"},
|
||||
{name: "explicit version selected", args: []string{"--prompt", "versioned", "--prompt-version", "2"}, wantCode: ExitOK, wantText: "prompt_version: 2"},
|
||||
{name: "multiple versions require selection", args: []string{"--prompt", "versioned"}, wantCode: ExitRuntimeError, wantText: "duplicate prompt definition id"},
|
||||
{name: "no declared inputs", args: []string{"--prompt", "sole"}, wantCode: ExitOK, wantText: "prompt: sole"},
|
||||
{name: "optional input omitted", args: []string{"--prompt", "optional"}, wantCode: ExitOK, wantText: "prompt: optional"},
|
||||
{name: "required input omitted", args: []string{"--prompt", "required"}, wantCode: ExitRuntimeError, wantText: "required"},
|
||||
{name: "template input omitted", args: []string{"--prompt", "template"}, wantCode: ExitRuntimeError, wantText: "note"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
code, stdout, stderr := runCLICommand(t, renderCommand, append(append([]string{}, baseArgs...), tc.args...))
|
||||
if code != tc.wantCode {
|
||||
t.Fatalf("expected exit %d, got %d stderr=%q", tc.wantCode, code, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr, tc.wantText) {
|
||||
t.Fatalf("expected output to contain %q, stdout=%q stderr=%q", tc.wantText, stdout, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
|
||||
lib := newCLITestLibrary(t)
|
||||
inputPath := lib.writeInputFile(t, "transcript.md", "hello")
|
||||
@@ -1340,6 +1459,13 @@ func writePromptFile(t *testing.T, dir, id, defaultProfile string) {
|
||||
writePromptFileWithTemplate(t, dir, id, defaultProfile, "Summarize: {{input \"transcript\"}}")
|
||||
}
|
||||
|
||||
func writePromptDefinition(t *testing.T, dir, name, definition string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(definition), 0o644); err != nil {
|
||||
t.Fatalf("write prompt definition: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) {
|
||||
t.Helper()
|
||||
data := fmt.Sprintf(`id: %s
|
||||
|
||||
Reference in New Issue
Block a user