package scriptorium import ( "context" "errors" "fmt" "net/http" "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/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 } // 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 } } // NewEngine constructs an Engine using the same default internal components as // the CLI and HTTP adapters. func NewEngine(cfg Config, opts ...Option) (*Engine, error) { if strings.TrimSpace(cfg.PromptDir) == "" { return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig) } if strings.TrimSpace(cfg.ProfileDir) == "" { return nil, fmt.Errorf("%w: profile directory is required", ErrInvalidConfig) } 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) } } schemaDir := cfg.SchemaDir if strings.TrimSpace(schemaDir) == "" { schemaDir = defaults.SchemaDirDefault } 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( promptdef.NewFilesystemRepository(cfg.PromptDir), profile.NewFilesystemRepository(cfg.ProfileDir), artifactadapter.NewCompositeReader(), prompt.NewGoRenderer(), llmClient, validate.NewStandardValidator(schemaDir), ), }, 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 }