Add scriptorium render command with shared run/request parsing and text/json prepared output

This commit is contained in:
2026-05-06 15:20:22 +00:00
parent 5328f04f6d
commit ea27763320
2 changed files with 460 additions and 83 deletions

View File

@@ -16,6 +16,7 @@ import (
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
@@ -56,6 +57,11 @@ type runConfig struct {
timeoutSet bool
}
type renderConfig struct {
runConfig
outputFormat renderformat.PreparedRunOutputFormat
}
type serveConfig struct {
addr string
promptDir string
@@ -83,6 +89,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
switch args[0] {
case "run":
return runCommand(args[1:], stdout, stderr)
case "render":
return renderCommand(args[1:], stdout, stderr)
case "serve":
return serveCommand(args[1:], stderr)
default:
@@ -99,24 +107,11 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
return ExitRuntimeError
}
inputMappings, err := parseMappings(cfg.inputRaw, false)
req, err := buildRunRequestFromConfig(cfg)
if err != nil {
fmt.Fprintf(stderr, "input parse error: %v\n", err)
fmt.Fprintf(stderr, "run parse error: %v\n", err)
return ExitRuntimeError
}
varMappings := map[string]string{}
if len(cfg.varRaw) > 0 {
varMappings, err = parseMappings(cfg.varRaw, false)
if err != nil {
fmt.Fprintf(stderr, "var parse error: %v\n", err)
return ExitRuntimeError
}
}
inputs := make(map[string]domain.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
}
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
Timeout: defaults.LLMRequestTimeoutDefault,
@@ -135,28 +130,7 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
validate.NewStandardValidator(cfg.schemaDir),
)
var modelOverride *domain.ExecutionTarget
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
modelOverride = &domain.ExecutionTarget{
Endpoint: cfg.llmBaseURL,
Model: cfg.model,
Temperature: cfg.temperature,
MaxTokens: cfg.maxTokens,
TopP: cfg.topP,
APIKeyEnv: cfg.apiKeyEnv,
}
if cfg.timeoutSet {
modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds())
}
}
res, runErr := runner.Run(context.Background(), domain.RunRequest{
PromptID: cfg.promptID,
ProfileID: cfg.profileID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
})
res, runErr := runner.Run(context.Background(), req)
if runErr != nil {
fmt.Fprintf(stderr, "run error: %v\n", runErr)
return ExitRuntimeError
@@ -171,6 +145,47 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
return determineExitCode(nil, res)
}
func renderCommand(args []string, stdout, stderr io.Writer) int {
cfg, err := parseRenderArgs(args)
if err != nil {
fmt.Fprintf(stderr, "render parse error: %v\n", err)
return ExitRuntimeError
}
req, err := buildRunRequestFromConfig(&cfg.runConfig)
if err != nil {
fmt.Fprintf(stderr, "render parse error: %v\n", err)
return ExitRuntimeError
}
runner := usecase.NewRunner(
promptdef.NewFilesystemRepository(cfg.promptDir),
profile.NewFilesystemRepository(cfg.profileDir),
artifactadapter.NewCompositeReader(),
prompt.NewGoRenderer(),
nil,
nil,
)
prepared, prepErr := runner.Prepare(context.Background(), req)
if prepErr != nil {
fmt.Fprintf(stderr, "render error: %v\n", prepErr)
return ExitRuntimeError
}
out, err := renderformat.FormatPreparedRun(prepared, cfg.outputFormat)
if err != nil {
fmt.Fprintf(stderr, "render format error: %v\n", err)
return ExitRuntimeError
}
if err := writeOutput(stdout, cfg.outputPath, out); err != nil {
fmt.Fprintf(stderr, "output write error: %v\n", err)
return ExitRuntimeError
}
return ExitOK
}
func serveCommand(args []string, stderr io.Writer) int {
cfg, err := parseServeArgs(args)
if err != nil {
@@ -215,60 +230,47 @@ func parseRunArgs(args []string) (*runConfig, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
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.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)")
fs.StringVar(&cfg.outputPath, "out", "", "optional output file path")
fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1")
fs.StringVar(&cfg.apiKeyEnv, "api-key-env", "", "environment variable name containing API key")
fs.StringVar(&cfg.model, "model", "", "model name")
fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override")
fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override")
fs.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override")
registerExecutionRequestFlags(fs, cfg)
fs.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas")
fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout")
fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt")
fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile")
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() > 0 {
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
if err := finalizeExecutionRequestConfig(fs, cfg); err != nil {
return nil, err
}
if strings.TrimSpace(cfg.promptDir) == "" {
return nil, errors.New("--prompt-dir is required")
}
if strings.TrimSpace(cfg.profileDir) == "" {
return nil, errors.New("--profile-dir is required")
}
if strings.TrimSpace(cfg.promptID) == "" {
return nil, errors.New("--prompt is required")
}
if len(cfg.inputRaw) == 0 {
return nil, errors.New("at least one --input is required")
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
cfg.profileDir = filepath.Clean(cfg.profileDir)
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
if cfg.outputPath != "" {
cfg.outputPath = filepath.Clean(cfg.outputPath)
}
cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url")
cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env")
cfg.modelSet = flagWasSet(fs, "model")
cfg.temperatureSet = flagWasSet(fs, "temperature")
cfg.maxTokensSet = flagWasSet(fs, "max-tokens")
cfg.topPSet = flagWasSet(fs, "top-p")
cfg.timeoutSet = flagWasSet(fs, "timeout")
return cfg, nil
}
func parseRenderArgs(args []string) (*renderConfig, error) {
cfg := &renderConfig{
outputFormat: renderformat.DefaultPreparedRunOutputFormat,
}
fs := flag.NewFlagSet("render", flag.ContinueOnError)
fs.SetOutput(io.Discard)
registerExecutionRequestFlags(fs, &cfg.runConfig)
var rawFormat string
fs.StringVar(&rawFormat, "format", string(renderformat.DefaultPreparedRunOutputFormat), "render output format (text|json)")
if err := fs.Parse(args); err != nil {
return nil, err
}
if err := finalizeExecutionRequestConfig(fs, &cfg.runConfig); err != nil {
return nil, err
}
format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
if err != nil {
return nil, err
}
cfg.outputFormat = format
return cfg, nil
}
func parseServeArgs(args []string) (*serveConfig, error) {
cfg := &serveConfig{}
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
@@ -299,6 +301,100 @@ func parseServeArgs(args []string) (*serveConfig, error) {
return cfg, nil
}
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.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)")
fs.StringVar(&cfg.outputPath, "out", "", "optional output file path")
fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1")
fs.StringVar(&cfg.apiKeyEnv, "api-key-env", "", "environment variable name containing API key")
fs.StringVar(&cfg.model, "model", "", "model name")
fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override")
fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override")
fs.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override")
fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout")
fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt")
fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile")
}
func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
if fs.NArg() > 0 {
return fmt.Errorf("unexpected positional args: %v", fs.Args())
}
if strings.TrimSpace(cfg.promptDir) == "" {
return errors.New("--prompt-dir is required")
}
if strings.TrimSpace(cfg.profileDir) == "" {
return errors.New("--profile-dir is required")
}
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)
cfg.profileDir = filepath.Clean(cfg.profileDir)
if cfg.outputPath != "" {
cfg.outputPath = filepath.Clean(cfg.outputPath)
}
cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url")
cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env")
cfg.modelSet = flagWasSet(fs, "model")
cfg.temperatureSet = flagWasSet(fs, "temperature")
cfg.maxTokensSet = flagWasSet(fs, "max-tokens")
cfg.topPSet = flagWasSet(fs, "top-p")
cfg.timeoutSet = flagWasSet(fs, "timeout")
return nil
}
func buildRunRequestFromConfig(cfg *runConfig) (domain.RunRequest, error) {
inputMappings, err := parseMappings(cfg.inputRaw, false)
if err != nil {
return domain.RunRequest{}, fmt.Errorf("input parse error: %w", err)
}
varMappings := map[string]string{}
if len(cfg.varRaw) > 0 {
varMappings, err = parseMappings(cfg.varRaw, false)
if err != nil {
return domain.RunRequest{}, fmt.Errorf("var parse error: %w", err)
}
}
inputs := make(map[string]domain.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
}
var modelOverride *domain.ExecutionTarget
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
modelOverride = &domain.ExecutionTarget{
Endpoint: cfg.llmBaseURL,
Model: cfg.model,
Temperature: cfg.temperature,
MaxTokens: cfg.maxTokens,
TopP: cfg.topP,
APIKeyEnv: cfg.apiKeyEnv,
}
if cfg.timeoutSet {
modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds())
}
}
return domain.RunRequest{
PromptID: cfg.promptID,
ProfileID: cfg.profileID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
}, nil
}
func parseMappings(raw []string, allowEmptyValue bool) (map[string]string, error) {
out := make(map[string]string)
for _, entry := range raw {
@@ -385,7 +481,8 @@ func printSummary(stderr io.Writer, res *domain.RunResult) {
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|serve> ...")
fmt.Fprintln(w, "usage: scriptorium <run|render|serve> ...")
fmt.Fprintln(w, " run: scriptorium run --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 --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.Fprintf(w, " serve: scriptorium serve --addr %s --prompt-dir DIR --profile-dir DIR [--schema-dir DIR]\n", defaults.HTTPAddrDefault)
}