Add scriptorium render command with shared run/request parsing and text/json prepared output
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"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/llm"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
|
||||||
@@ -56,6 +57,11 @@ type runConfig struct {
|
|||||||
timeoutSet bool
|
timeoutSet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type renderConfig struct {
|
||||||
|
runConfig
|
||||||
|
outputFormat renderformat.PreparedRunOutputFormat
|
||||||
|
}
|
||||||
|
|
||||||
type serveConfig struct {
|
type serveConfig struct {
|
||||||
addr string
|
addr string
|
||||||
promptDir string
|
promptDir string
|
||||||
@@ -83,6 +89,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "run":
|
case "run":
|
||||||
return runCommand(args[1:], stdout, stderr)
|
return runCommand(args[1:], stdout, stderr)
|
||||||
|
case "render":
|
||||||
|
return renderCommand(args[1:], stdout, stderr)
|
||||||
case "serve":
|
case "serve":
|
||||||
return serveCommand(args[1:], stderr)
|
return serveCommand(args[1:], stderr)
|
||||||
default:
|
default:
|
||||||
@@ -99,24 +107,11 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
inputMappings, err := parseMappings(cfg.inputRaw, false)
|
req, err := buildRunRequestFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "input parse error: %v\n", err)
|
fmt.Fprintf(stderr, "run parse error: %v\n", err)
|
||||||
return ExitRuntimeError
|
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{
|
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
||||||
Timeout: defaults.LLMRequestTimeoutDefault,
|
Timeout: defaults.LLMRequestTimeoutDefault,
|
||||||
@@ -135,28 +130,7 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
validate.NewStandardValidator(cfg.schemaDir),
|
validate.NewStandardValidator(cfg.schemaDir),
|
||||||
)
|
)
|
||||||
|
|
||||||
var modelOverride *domain.ExecutionTarget
|
res, runErr := runner.Run(context.Background(), req)
|
||||||
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,
|
|
||||||
})
|
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
fmt.Fprintf(stderr, "run error: %v\n", runErr)
|
fmt.Fprintf(stderr, "run error: %v\n", runErr)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
@@ -171,6 +145,47 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
return determineExitCode(nil, res)
|
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 {
|
func serveCommand(args []string, stderr io.Writer) int {
|
||||||
cfg, err := parseServeArgs(args)
|
cfg, err := parseServeArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,60 +230,47 @@ func parseRunArgs(args []string) (*runConfig, error) {
|
|||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
|
registerExecutionRequestFlags(fs, cfg)
|
||||||
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.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas")
|
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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if fs.NArg() > 0 {
|
if err := finalizeExecutionRequestConfig(fs, cfg); err != nil {
|
||||||
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
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)
|
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
|
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) {
|
func parseServeArgs(args []string) (*serveConfig, error) {
|
||||||
cfg := &serveConfig{}
|
cfg := &serveConfig{}
|
||||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||||
@@ -299,6 +301,100 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
return cfg, nil
|
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) {
|
func parseMappings(raw []string, allowEmptyValue bool) (map[string]string, error) {
|
||||||
out := make(map[string]string)
|
out := make(map[string]string)
|
||||||
for _, entry := range raw {
|
for _, entry := range raw {
|
||||||
@@ -385,7 +481,8 @@ func printSummary(stderr io.Writer, res *domain.RunResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func printUsage(w io.Writer) {
|
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, " 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)
|
fmt.Fprintf(w, " serve: scriptorium serve --addr %s --prompt-dir DIR --profile-dir DIR [--schema-dir DIR]\n", defaults.HTTPAddrDefault)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
|
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseMappingsSingleAndRepeated(t *testing.T) {
|
func TestParseMappingsSingleAndRepeated(t *testing.T) {
|
||||||
@@ -205,6 +207,65 @@ func TestParseRunArgsTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRenderArgsDefaultsAndFormat(t *testing.T) {
|
||||||
|
cfg, err := parseRenderArgs([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
"--profile-dir", "./profiles",
|
||||||
|
"--prompt", "p",
|
||||||
|
"--input", "a=b",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected valid render args, got %v", err)
|
||||||
|
}
|
||||||
|
if cfg.outputFormat != renderformat.DefaultPreparedRunOutputFormat {
|
||||||
|
t.Fatalf("expected default render format %q, got %q", renderformat.DefaultPreparedRunOutputFormat, cfg.outputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRenderArgsExplicitFormatsAndUnknown(t *testing.T) {
|
||||||
|
cfg, err := parseRenderArgs([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
"--profile-dir", "./profiles",
|
||||||
|
"--prompt", "p",
|
||||||
|
"--input", "a=b",
|
||||||
|
"--format", "text",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected valid text format, got %v", err)
|
||||||
|
}
|
||||||
|
if cfg.outputFormat != renderformat.PreparedRunFormatText {
|
||||||
|
t.Fatalf("expected text format, got %q", cfg.outputFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = parseRenderArgs([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
"--profile-dir", "./profiles",
|
||||||
|
"--prompt", "p",
|
||||||
|
"--input", "a=b",
|
||||||
|
"--format", "json",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected valid json format, got %v", err)
|
||||||
|
}
|
||||||
|
if cfg.outputFormat != renderformat.PreparedRunFormatJSON {
|
||||||
|
t.Fatalf("expected json format, got %q", cfg.outputFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = parseRenderArgs([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
"--profile-dir", "./profiles",
|
||||||
|
"--prompt", "p",
|
||||||
|
"--input", "a=b",
|
||||||
|
"--format", "yaml",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unknown format error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown prepared run format") {
|
||||||
|
t.Fatalf("expected clear unknown format error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetermineExitCode(t *testing.T) {
|
func TestDetermineExitCode(t *testing.T) {
|
||||||
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
|
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
|
||||||
t.Fatalf("expected runtime exit code, got %d", got)
|
t.Fatalf("expected runtime exit code, got %d", got)
|
||||||
@@ -247,6 +308,220 @@ func TestRunCommandVarsOptional(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *testing.T) {
|
||||||
|
const envName = "SCRIPTORIUM_RENDER_TEST_API_KEY"
|
||||||
|
const secret = "super-secret-render-key"
|
||||||
|
t.Setenv(envName, secret)
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
promptDir := filepath.Join(tmp, "prompts")
|
||||||
|
profileDir := filepath.Join(tmp, "profiles")
|
||||||
|
if err := os.MkdirAll(promptDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputPath := filepath.Join(tmp, "transcript.md")
|
||||||
|
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writePromptFileWithTemplate(t, promptDir, "prompt.render", "local-default", "Date {{.session_date}} - Summarize: {{input \"transcript\"}}")
|
||||||
|
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := renderCommand([]string{
|
||||||
|
"--prompt-dir", promptDir,
|
||||||
|
"--profile-dir", profileDir,
|
||||||
|
"--prompt", "prompt.render",
|
||||||
|
"--profile", "local-default",
|
||||||
|
"--input", "transcript=" + inputPath,
|
||||||
|
"--var", "session_date=2026-05-04",
|
||||||
|
"--llm-base-url", "http://override.local/v1",
|
||||||
|
"--model", "override-model",
|
||||||
|
"--temperature", "0.7",
|
||||||
|
"--max-tokens", "55",
|
||||||
|
"--top-p", "0.2",
|
||||||
|
"--timeout", "20s",
|
||||||
|
"--api-key-env", envName,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != ExitOK {
|
||||||
|
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"prompt: prompt.render",
|
||||||
|
"selected_profile_id: local-default",
|
||||||
|
"endpoint: http://override.local/v1",
|
||||||
|
"model: override-model",
|
||||||
|
"temperature: 0.7",
|
||||||
|
"max_tokens: 55",
|
||||||
|
"top_p: 0.2",
|
||||||
|
"timeout_seconds: 20",
|
||||||
|
"api_key_env: " + envName,
|
||||||
|
"rendered_prompt_hash:",
|
||||||
|
"messages:",
|
||||||
|
"Date 2026-05-04",
|
||||||
|
"Summarize:",
|
||||||
|
"hello transcript",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("expected render text output to include %q, got:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, secret) {
|
||||||
|
t.Fatalf("render output unexpectedly contained secret api key value: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCommandExplicitTextFormatWorks(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
promptDir := filepath.Join(tmp, "prompts")
|
||||||
|
profileDir := filepath.Join(tmp, "profiles")
|
||||||
|
if err := os.MkdirAll(promptDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputPath := filepath.Join(tmp, "transcript.md")
|
||||||
|
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writePromptFile(t, promptDir, "prompt.render", "local-default")
|
||||||
|
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := renderCommand([]string{
|
||||||
|
"--prompt-dir", promptDir,
|
||||||
|
"--profile-dir", profileDir,
|
||||||
|
"--prompt", "prompt.render",
|
||||||
|
"--input", "transcript=" + inputPath,
|
||||||
|
"--format", "text",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != ExitOK {
|
||||||
|
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "prompt: prompt.render") {
|
||||||
|
t.Fatalf("expected text output for explicit --format text, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCommandExplicitJSONFormatOutputsValidJSON(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
promptDir := filepath.Join(tmp, "prompts")
|
||||||
|
profileDir := filepath.Join(tmp, "profiles")
|
||||||
|
if err := os.MkdirAll(promptDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputPath := filepath.Join(tmp, "transcript.md")
|
||||||
|
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writePromptFile(t, promptDir, "prompt.render", "local-default")
|
||||||
|
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := renderCommand([]string{
|
||||||
|
"--prompt-dir", promptDir,
|
||||||
|
"--profile-dir", profileDir,
|
||||||
|
"--prompt", "prompt.render",
|
||||||
|
"--input", "transcript=" + inputPath,
|
||||||
|
"--format", "json",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != ExitOK {
|
||||||
|
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("expected valid json output, got %v\nbody=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
if payload["prompt_id"] != "prompt.render" {
|
||||||
|
t.Fatalf("expected prompt_id, got %#v", payload["prompt_id"])
|
||||||
|
}
|
||||||
|
if payload["selected_profile_id"] != "local-default" {
|
||||||
|
t.Fatalf("expected selected_profile_id, got %#v", payload["selected_profile_id"])
|
||||||
|
}
|
||||||
|
if _, ok := payload["messages"]; !ok {
|
||||||
|
t.Fatalf("expected messages in render json output, got %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCommandUnknownFormatFailsClearly(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := renderCommand([]string{
|
||||||
|
"--prompt-dir", "./prompts",
|
||||||
|
"--profile-dir", "./profiles",
|
||||||
|
"--prompt", "p",
|
||||||
|
"--input", "transcript=./x.md",
|
||||||
|
"--format", "yaml",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != ExitRuntimeError {
|
||||||
|
t.Fatalf("expected ExitRuntimeError, got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "render parse error") || !strings.Contains(stderr.String(), "unknown prepared run format") {
|
||||||
|
t.Fatalf("expected clear unknown-format parse error, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCommandOutWritesToFile(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
promptDir := filepath.Join(tmp, "prompts")
|
||||||
|
profileDir := filepath.Join(tmp, "profiles")
|
||||||
|
if err := os.MkdirAll(promptDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inputPath := filepath.Join(tmp, "transcript.md")
|
||||||
|
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outPath := filepath.Join(tmp, "render.txt")
|
||||||
|
|
||||||
|
writePromptFile(t, promptDir, "prompt.render", "local-default")
|
||||||
|
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := renderCommand([]string{
|
||||||
|
"--prompt-dir", promptDir,
|
||||||
|
"--profile-dir", profileDir,
|
||||||
|
"--prompt", "prompt.render",
|
||||||
|
"--input", "transcript=" + inputPath,
|
||||||
|
"--out", outPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != ExitOK {
|
||||||
|
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty stdout when --out is set, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
out, err := os.ReadFile(outPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed reading render output file: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "prompt: prompt.render") {
|
||||||
|
t.Fatalf("expected render output in file, got %q", string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
|
func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
promptDir := filepath.Join(tmp, "prompts")
|
promptDir := filepath.Join(tmp, "prompts")
|
||||||
@@ -425,6 +700,11 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writePromptFile(t *testing.T, dir, id, defaultProfile string) {
|
func writePromptFile(t *testing.T, dir, id, defaultProfile string) {
|
||||||
|
t.Helper()
|
||||||
|
writePromptFileWithTemplate(t, dir, id, defaultProfile, "Summarize: {{input \"transcript\"}}")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data := fmt.Sprintf(`id: %s
|
data := fmt.Sprintf(`id: %s
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
@@ -434,12 +714,12 @@ inputs:
|
|||||||
required: true
|
required: true
|
||||||
messages:
|
messages:
|
||||||
- role: user
|
- role: user
|
||||||
content: "Summarize: {{input \"transcript\"}}"
|
content: %q
|
||||||
output:
|
output:
|
||||||
format: text
|
format: text
|
||||||
validation_mode: none
|
validation_mode: none
|
||||||
repair_attempts: 0
|
repair_attempts: 0
|
||||||
`, id, defaultProfile)
|
`, id, defaultProfile, templateContent)
|
||||||
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil {
|
||||||
t.Fatalf("failed to write prompt fixture: %v", err)
|
t.Fatalf("failed to write prompt fixture: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user