Files
promptkit/internal/engine/engine.go

431 lines
13 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 {
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)
}