// 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 { ListBackends(context.Context) ([]string, error) ListProfiles(context.Context) ([]string, error) ListPrompts(context.Context) ([]domain.PromptIdentity, error) ListNamespaces(context.Context, []string) ([]string, error) ListCollections(context.Context) ([]configuration.CollectionIdentity, error) } // ListingFilterMode selects namespace membership for one identity listing. type ListingFilterMode uint8 const ( ListingDefault ListingFilterMode = iota ListingNamed ListingUnqualified ListingAll ) // ListingFilter optionally overrides the namespace selected by an engine view. type ListingFilter struct { Mode ListingFilterMode Name string } // 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. Caller-supplied selection IDs are // qualified at each operation boundary; captured configuration references are // not rewritten. 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) { var err error id, err = e.qualifyResourceID(id, e.namespaces.prompt, "prompt") if err != nil { return nil, err } return e.runner.InspectPrompt(ctx, id) } func (e *Engine) InspectRequirements( ctx context.Context, request usecase.RequirementInspectionRequest, ) (*domain.RequirementInspection, error) { var err error request.PromptID, err = e.qualifyResourceID(request.PromptID, e.namespaces.prompt, "prompt") if err != nil { return nil, err } request.ProfileID, err = e.qualifyResourceID(request.ProfileID, e.namespaces.profile, "profile") if err != nil { return nil, err } return e.runner.InspectRequirements(ctx, request) } func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) { var err error id, err = e.qualifyResourceID(id, e.namespaces.profile, "profile") if err != nil { return nil, err } return e.runner.InspectProfile(ctx, id) } func (e *Engine) ListBackends(ctx context.Context, filters ...ListingFilter) ([]string, error) { return filterIdentityList(ctx, filters, e.namespaces.backend, e.listing.ListBackends) } func (e *Engine) ListProfiles(ctx context.Context, filters ...ListingFilter) ([]string, error) { return filterIdentityList(ctx, filters, e.namespaces.profile, e.listing.ListProfiles) } func (e *Engine) ListPrompts(ctx context.Context, filters ...ListingFilter) ([]domain.PromptIdentity, error) { mode, namespace, err := resolveListingFilter(filters, e.namespaces.prompt) if err != nil { return nil, err } prompts, err := e.listing.ListPrompts(ctx) if err != nil { return nil, err } var filtered []domain.PromptIdentity for _, prompt := range prompts { if matchesListingNamespace(prompt.ID, mode, namespace) { filtered = append(filtered, prompt) } } if err := ctx.Err(); err != nil { return nil, err } return filtered, nil } func (e *Engine) ListNamespaces(ctx context.Context, kinds []string) ([]string, error) { for _, kind := range kinds { switch kind { case "prompt", "profile", "backend": default: return nil, fmt.Errorf("%w: unknown resource kind %q", usecase.ErrInvalidRequest, kind) } } return e.listing.ListNamespaces(ctx, kinds) } func (e *Engine) ListCollections(ctx context.Context) ([]configuration.CollectionIdentity, error) { return e.listing.ListCollections(ctx) } func filterIdentityList( ctx context.Context, filters []ListingFilter, scope string, list func(context.Context) ([]string, error), ) ([]string, error) { mode, namespace, err := resolveListingFilter(filters, scope) if err != nil { return nil, err } ids, err := list(ctx) if err != nil { return nil, err } var filtered []string for _, id := range ids { if matchesListingNamespace(id, mode, namespace) { filtered = append(filtered, id) } } if err := ctx.Err(); err != nil { return nil, err } return filtered, nil } func resolveListingFilter(filters []ListingFilter, scope string) (ListingFilterMode, string, error) { if len(filters) > 1 { return 0, "", fmt.Errorf("%w: at most one listing filter is allowed", usecase.ErrInvalidRequest) } filter := ListingFilter{} if len(filters) == 1 { filter = filters[0] } switch filter.Mode { case ListingDefault: if scope == "" { return ListingAll, "", nil } return ListingNamed, scope, nil case ListingNamed: if !domain.IsValidResourceNamespace(filter.Name) { return 0, "", fmt.Errorf("%w: invalid listing namespace", usecase.ErrInvalidRequest) } return ListingNamed, filter.Name, nil case ListingUnqualified, ListingAll: return filter.Mode, "", nil default: return 0, "", fmt.Errorf("%w: unknown listing filter mode", usecase.ErrInvalidRequest) } } func matchesListingNamespace(id string, mode ListingFilterMode, namespace string) bool { switch mode { case ListingNamed: idNamespace, _, qualified := strings.Cut(id, "/") return qualified && idNamespace == namespace case ListingUnqualified: return !strings.Contains(id, "/") case ListingAll: return true default: return false } } 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 err error request.PromptID, err = e.qualifyResourceID(request.PromptID, e.namespaces.prompt, "prompt") if err != nil { return domain.RunRequest{}, err } request.ProfileID, err = e.qualifyResourceID(request.ProfileID, e.namespaces.profile, "profile") if err != nil { return domain.RunRequest{}, err } return request, nil } func (e *Engine) qualifyResourceID(id, namespace, kind string) (string, error) { if id == "" { return "", nil } qualified, ok := domain.QualifyResourceID(id, namespace) if !ok { return "", fmt.Errorf("%w: invalid %s ID", usecase.ErrInvalidRequest, kind) } return qualified, 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) } var err error request.PromptID, err = e.qualifyResourceID(request.PromptID, e.namespaces.prompt, "prompt") if err != nil { return nil, err } request.ProfileID, err = e.qualifyResourceID(request.ProfileID, e.namespaces.profile, "profile") if err != nil { return nil, err } request.BackendID, err = e.qualifyResourceID(request.BackendID, e.namespaces.backend, "backend") if err != nil { return nil, err } return e.smokeTester.Run(ctx, request) }