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) } domainReq, err := toDomainRunRequest(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } prepared, err := e.runner.Prepare(ctx, domainReq) 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) } domainReq, err := toDomainRunRequest(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } result, err := e.runner.Run(ctx, domainReq) if err != nil { return nil, mapPublicError(err) } return fromDomainRunResult(result), nil }