279 lines
7.4 KiB
Go
279 lines
7.4 KiB
Go
package scriptorium
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/profile/builtin"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/validate"
|
|
)
|
|
|
|
// ErrInvalidConfig indicates invalid public engine configuration.
|
|
var ErrInvalidConfig = errors.New("invalid engine configuration")
|
|
|
|
var (
|
|
ErrInvalidRequest = errors.New("invalid run request")
|
|
ErrPromptNotFound = errors.New("prompt not found")
|
|
ErrProfileNotFound = errors.New("profile not found")
|
|
ErrPromptLoad = errors.New("failed to load prompt definition")
|
|
ErrProfileLoad = errors.New("failed to load execution profile")
|
|
ErrArtifactLoad = errors.New("failed to load artifact")
|
|
ErrPromptRender = errors.New("failed to render prompt")
|
|
ErrLLMGenerate = errors.New("failed to generate output")
|
|
ErrValidation = errors.New("failed to validate output")
|
|
)
|
|
|
|
// Engine prepares and runs Scriptorium prompt requests.
|
|
type Engine struct {
|
|
runner *usecase.Runner
|
|
}
|
|
|
|
// Config configures a public Scriptorium engine.
|
|
type Config struct {
|
|
PromptDir string
|
|
ProfileDir string
|
|
SchemaDir string
|
|
Timeout time.Duration
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// Option customizes engine construction.
|
|
type Option func(*engineOptions) error
|
|
|
|
type engineOptions struct {
|
|
llmClient llm.Client
|
|
promptDefs promptdef.Repository
|
|
profiles profile.Repository
|
|
memoryProfiles profile.Repository
|
|
validator validate.Validator
|
|
promptSource bool
|
|
profileSource bool
|
|
memorySource bool
|
|
validatorSource bool
|
|
}
|
|
|
|
// WithLLMClient injects a custom LLM client for execution.
|
|
func WithLLMClient(client LLMClient) Option {
|
|
return func(options *engineOptions) error {
|
|
if client == nil {
|
|
return ErrInvalidConfig
|
|
}
|
|
options.llmClient = publicLLMClientAdapter{client: client}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithPromptFS(fsys fs.FS, root string) Option {
|
|
return func(options *engineOptions) error {
|
|
if fsys == nil {
|
|
return ErrInvalidConfig
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return ErrInvalidConfig
|
|
}
|
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
|
options.promptSource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithPromptFile(path string) Option {
|
|
return func(options *engineOptions) error {
|
|
fsys, root, err := fileSource(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
|
options.promptSource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithProfileFS(fsys fs.FS, root string) Option {
|
|
return func(options *engineOptions) error {
|
|
if fsys == nil {
|
|
return ErrInvalidConfig
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return ErrInvalidConfig
|
|
}
|
|
options.profiles = profile.NewFSRepository(fsys, root)
|
|
options.profileSource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithProfileFile(path string) Option {
|
|
return func(options *engineOptions) error {
|
|
fsys, root, err := fileSource(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
options.profiles = profile.NewFSRepository(fsys, root)
|
|
options.profileSource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WithProfiles configures in-memory profiles that take precedence over
|
|
// configured profile files and built-in profiles.
|
|
func WithProfiles(profiles ...Profile) Option {
|
|
return func(options *engineOptions) error {
|
|
repo, err := newMemoryProfileRepository(profiles)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
options.memoryProfiles = repo
|
|
options.memorySource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithSchemaFS(fsys fs.FS, root string) Option {
|
|
return func(options *engineOptions) error {
|
|
if fsys == nil {
|
|
return ErrInvalidConfig
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return ErrInvalidConfig
|
|
}
|
|
options.validator = validate.NewFSValidator(fsys, root)
|
|
options.validatorSource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func WithSchemaFile(path string) Option {
|
|
return func(options *engineOptions) error {
|
|
fsys, root, err := fileSource(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
options.validator = validate.NewFSValidator(fsys, root)
|
|
options.validatorSource = true
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NewEngine constructs an Engine using the same default internal components as
|
|
// the CLI and HTTP adapters.
|
|
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|
var options engineOptions
|
|
for _, opt := range opts {
|
|
if opt == nil {
|
|
continue
|
|
}
|
|
if err := opt(&options); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
|
}
|
|
}
|
|
|
|
promptDefs := options.promptDefs
|
|
if !options.promptSource {
|
|
if strings.TrimSpace(cfg.PromptDir) == "" {
|
|
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
|
|
}
|
|
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
|
}
|
|
|
|
profiles := builtin.NewRepositoryWithDirectory(cfg.ProfileDir)
|
|
if options.profileSource {
|
|
profiles = builtin.NewRepositoryWithPrimary(options.profiles)
|
|
}
|
|
if options.memorySource {
|
|
profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles)
|
|
}
|
|
|
|
validator := options.validator
|
|
if !options.validatorSource {
|
|
schemaDir := cfg.SchemaDir
|
|
if strings.TrimSpace(schemaDir) == "" {
|
|
schemaDir = defaults.SchemaDirDefault
|
|
}
|
|
validator = validate.NewStandardValidator(schemaDir)
|
|
}
|
|
|
|
llmClient := options.llmClient
|
|
if llmClient == nil {
|
|
var err error
|
|
llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
|
Timeout: cfg.Timeout,
|
|
HTTPClient: cfg.HTTPClient,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
|
}
|
|
}
|
|
|
|
return &Engine{
|
|
runner: usecase.NewRunner(
|
|
promptDefs,
|
|
profiles,
|
|
artifactadapter.NewCompositeReader(),
|
|
prompt.NewGoRenderer(),
|
|
llmClient,
|
|
validator,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
func fileSource(name string) (fs.FS, string, error) {
|
|
cleanName := strings.TrimSpace(name)
|
|
if cleanName == "" {
|
|
return nil, "", ErrInvalidConfig
|
|
}
|
|
dir := filepath.Dir(cleanName)
|
|
base := filepath.Base(cleanName)
|
|
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
|
|
return nil, "", ErrInvalidConfig
|
|
}
|
|
info, err := os.Stat(cleanName)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err)
|
|
}
|
|
if info.IsDir() {
|
|
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName)
|
|
}
|
|
return os.DirFS(dir), filepath.ToSlash(base), nil
|
|
}
|
|
|
|
// Prepare resolves a prompt request without calling an LLM.
|
|
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
|
if e == nil || e.runner == nil {
|
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
|
}
|
|
|
|
prepared, err := e.runner.Prepare(ctx, toDomainRunRequest(req))
|
|
if err != nil {
|
|
return nil, mapPublicError(err)
|
|
}
|
|
return fromDomainPreparedRun(prepared), nil
|
|
}
|
|
|
|
// Run executes a prompt request and returns the generated artifact and metadata.
|
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|
if e == nil || e.runner == nil {
|
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
|
}
|
|
|
|
result, err := e.runner.Run(ctx, toDomainRunRequest(req))
|
|
if err != nil {
|
|
return nil, mapPublicError(err)
|
|
}
|
|
return fromDomainRunResult(result), nil
|
|
}
|