268 lines
8.3 KiB
Go
268 lines
8.3 KiB
Go
// Package engine assembles Promptkit's internal execution components.
|
|
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/configuration"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
|
)
|
|
|
|
// Config carries construction settings selected by the public facade.
|
|
type Config struct {
|
|
Sources ConfigurationSources
|
|
SchemaDir string
|
|
Timeout time.Duration
|
|
HTTPClient *http.Client
|
|
TransientRetries *int
|
|
}
|
|
|
|
// ConfigurationSources identifies caller-selected ConfigKit layers.
|
|
type ConfigurationSources struct {
|
|
Underlays []configkit.Underlay
|
|
Main *configkit.Input
|
|
}
|
|
|
|
// Options collects construction choices from the public facade.
|
|
type Options struct {
|
|
llmClient llm.Client
|
|
artifactReader artifact.Reader
|
|
validator validate.Validator
|
|
validatorSource bool
|
|
artifactSource bool
|
|
}
|
|
|
|
// SetLLMClient selects the client collaborator for one engine.
|
|
func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client }
|
|
|
|
// SetArtifactReader selects the artifact collaborator for one engine.
|
|
func (o *Options) SetArtifactReader(reader artifact.Reader) {
|
|
o.artifactReader = reader
|
|
o.artifactSource = true
|
|
}
|
|
|
|
// SetSchemaFS selects a schema source rooted in fsys.
|
|
func (o *Options) SetSchemaFS(fsys fs.FS, root string) error {
|
|
if err := validateSourceFS(fsys, root); err != nil {
|
|
return err
|
|
}
|
|
o.validator = validate.NewFSValidator(fsys, root)
|
|
o.validatorSource = true
|
|
return nil
|
|
}
|
|
|
|
// SetSchemaFile selects a single schema file.
|
|
func (o *Options) SetSchemaFile(path string) error {
|
|
fsys, root, _, err := fileSource(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o.validator = validate.NewFSValidator(fsys, root)
|
|
o.validatorSource = true
|
|
return nil
|
|
}
|
|
|
|
// Engine forwards domain operations to the single assembled use-case runner.
|
|
type Engine struct {
|
|
runner *usecase.Runner
|
|
smokeTester *usecase.SmokeTester
|
|
inspection configuration.Inspection
|
|
listing identityInventory
|
|
namespaces resourceNamespaces
|
|
}
|
|
|
|
type resourceNamespaces struct {
|
|
prompt string
|
|
profile string
|
|
backend string
|
|
}
|
|
|
|
type identityInventory interface {
|
|
ListProfiles(context.Context) ([]string, error)
|
|
ListPrompts(context.Context) ([]domain.PromptIdentity, error)
|
|
}
|
|
|
|
// New assembles an engine from facade-selected configuration and collaborators.
|
|
func New(cfg Config, options Options) (*Engine, error) {
|
|
defaultRetries, err := domain.ResolveTransientRetries(2, cfg.TransientRetries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
underlays := cfg.Sources.Underlays
|
|
main := configkit.Input{Source: configkit.NoSource()}
|
|
if cfg.Sources.Main != nil {
|
|
main = *cfg.Sources.Main
|
|
}
|
|
snapshot, err := configuration.Load(main, underlays)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
manager, err := capacity.NewManager(snapshot.CapacityPolicies())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to construct backend capacity manager: %w", err)
|
|
}
|
|
|
|
validator := options.validator
|
|
if !options.validatorSource {
|
|
schemaDir := cfg.SchemaDir
|
|
if strings.TrimSpace(schemaDir) == "" {
|
|
schemaDir = defaults.SchemaDirDefault
|
|
}
|
|
validator = validate.NewStandardValidator(schemaDir)
|
|
}
|
|
var client llm.Client
|
|
var diagnosticHTTPClient *llm.OpenAICompatibleClient
|
|
if options.llmClient == nil {
|
|
builtIn, newErr := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
|
|
if newErr != nil {
|
|
return nil, newErr
|
|
}
|
|
diagnosticHTTPClient = builtIn
|
|
client = generation.NewBuiltIn(manager, builtIn)
|
|
} else {
|
|
client = generation.NewInjected(manager, options.llmClient)
|
|
}
|
|
reader := options.artifactReader
|
|
if !options.artifactSource {
|
|
reader = artifact.NewCompositeReader()
|
|
}
|
|
|
|
runner := usecase.NewRunnerWithRepairer(snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)
|
|
return &Engine{
|
|
runner: runner,
|
|
smokeTester: usecase.NewSmokeTester(runner, diagnosticHTTPClient),
|
|
inspection: snapshot.Inspection(),
|
|
listing: snapshot,
|
|
}, nil
|
|
}
|
|
|
|
// WithNamespaces returns an immutable view sharing this engine's assembled
|
|
// collaborators and configuration snapshot.
|
|
func (e *Engine) WithNamespaces(prompt, profile, backend string) *Engine {
|
|
view := *e
|
|
view.namespaces = resourceNamespaces{prompt: prompt, profile: profile, backend: backend}
|
|
return &view
|
|
}
|
|
|
|
// InspectConfiguration returns read-only operations over the captured result.
|
|
func (e *Engine) InspectConfiguration() configuration.Inspection {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.inspection
|
|
}
|
|
|
|
func validateSourceFS(fsys fs.FS, root string) error {
|
|
if fsys == nil {
|
|
return fmt.Errorf("source filesystem is nil")
|
|
}
|
|
if strings.TrimSpace(root) == "" {
|
|
return fmt.Errorf("source root is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fileSource(name string) (fs.FS, string, string, error) {
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, "", "", fmt.Errorf("source path is required")
|
|
}
|
|
directory := filepath.Dir(name)
|
|
base := filepath.Base(name)
|
|
if base == "." || base == string(filepath.Separator) {
|
|
return nil, "", "", fmt.Errorf("source path must name a file")
|
|
}
|
|
info, err := os.Stat(name)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("failed to access source file %q: %w", name, err)
|
|
}
|
|
if info.IsDir() {
|
|
return nil, "", "", fmt.Errorf("source path %q must be a file", name)
|
|
}
|
|
return os.DirFS(directory), filepath.ToSlash(base), directory, nil
|
|
}
|
|
|
|
func (e *Engine) InspectPrompt(ctx context.Context, id string) (*domain.PromptInspection, error) {
|
|
return e.runner.InspectPrompt(ctx, id)
|
|
}
|
|
func (e *Engine) InspectRequirements(
|
|
ctx context.Context,
|
|
request usecase.RequirementInspectionRequest,
|
|
) (*domain.RequirementInspection, error) {
|
|
return e.runner.InspectRequirements(ctx, request)
|
|
}
|
|
func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) {
|
|
return e.runner.InspectProfile(ctx, id)
|
|
}
|
|
|
|
func (e *Engine) ListProfiles(ctx context.Context) ([]string, error) {
|
|
return e.listing.ListProfiles(ctx)
|
|
}
|
|
|
|
func (e *Engine) ListPrompts(ctx context.Context) ([]domain.PromptIdentity, error) {
|
|
return e.listing.ListPrompts(ctx)
|
|
}
|
|
func (e *Engine) Prepare(ctx context.Context, request domain.RunRequest) (*domain.PreparedRun, error) {
|
|
request, err := e.qualifyRunRequest(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return e.runner.Prepare(ctx, request)
|
|
}
|
|
func (e *Engine) PrepareExecution(ctx context.Context, request domain.RunRequest) (*usecase.PreparedExecution, error) {
|
|
request, err := e.qualifyRunRequest(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return e.runner.PrepareExecution(ctx, request)
|
|
}
|
|
func (e *Engine) Run(ctx context.Context, request domain.RunRequest) (*domain.RunResult, error) {
|
|
request, err := e.qualifyRunRequest(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return e.runner.Run(ctx, request)
|
|
}
|
|
|
|
func (e *Engine) qualifyRunRequest(request domain.RunRequest) (domain.RunRequest, error) {
|
|
var ok bool
|
|
if request.PromptID != "" {
|
|
request.PromptID, ok = domain.QualifyResourceID(request.PromptID, e.namespaces.prompt)
|
|
if !ok {
|
|
return domain.RunRequest{}, fmt.Errorf("%w: invalid prompt ID", usecase.ErrInvalidRequest)
|
|
}
|
|
}
|
|
if request.ProfileID != "" {
|
|
request.ProfileID, ok = domain.QualifyResourceID(request.ProfileID, e.namespaces.profile)
|
|
if !ok {
|
|
return domain.RunRequest{}, fmt.Errorf("%w: invalid profile ID", usecase.ErrInvalidRequest)
|
|
}
|
|
}
|
|
return request, nil
|
|
}
|
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *usecase.PreparedExecution) (*domain.RunResult, error) {
|
|
return e.runner.RunPrepared(ctx, prepared)
|
|
}
|
|
|
|
func (e *Engine) SmokeTest(ctx context.Context, request domain.SmokeTestRequest) (*domain.SmokeTestReport, error) {
|
|
if e == nil || e.smokeTester == nil {
|
|
return nil, fmt.Errorf("%w: smoke tester is not configured", usecase.ErrInvalidRequest)
|
|
}
|
|
return e.smokeTester.Run(ctx, request)
|
|
}
|