Move option source construction into the internal engine

This commit is contained in:
2026-09-22 04:44:41 +00:00
parent bd2b7b1862
commit 47bc9a6655
9 changed files with 190 additions and 157 deletions

View File

@@ -93,7 +93,7 @@ func WithBackend(backend Backend) Option {
queueCapacity = *backend.QueueCapacity
}
return optionFunc(func(options *engineOptions) error {
options.backends = append(options.backends, domain.Backend{
options.internal.AddBackend(domain.Backend{
ID: backend.ID,
Endpoint: backend.Endpoint,
APIKeyEnv: backend.APIKeyEnv,

View File

@@ -14,7 +14,7 @@ stable JSON values.
## Construction And Pool Lifecycle
Each root `NewEngine` call obtains a normalized capacity-policy snapshot from
Each internal engine construction obtains a normalized capacity-policy snapshot from
its immutable backend registry and constructs a new `Manager`. The manager
creates one pool for each limited backend ID. It has no package-global mutable
state, background workers, shutdown protocol, or persistence, so engines with

View File

@@ -10,7 +10,7 @@ owns the observable outbound HTTP contract. The
[framework format reference](../formats.md) owns the profile and prompt
settings consumed by the client.
The concrete client remains under `internal/llm`. The root engine assembles it
The concrete client remains under `internal/llm`. The internal engine assembles it
as the default implementation behind Promptkit's public client boundary.
## Components And Flow

View File

@@ -30,7 +30,7 @@ contributor workflow and validation.
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
The root package assembles these internal components without exposing their
The internal engine assembles these internal components without exposing their
representations. Consumers depend only on the root facade.
## Maintenance

View File

@@ -69,7 +69,7 @@ repositories described above.
`internal/catalog` validates the imported immutable OpenRouter and
Rakestrawhome asset modules as one private adapter boundary. It enforces their
manifest, layout, profile ownership, inheritance, and secret-safety rules
before returning raw catalog sources. Root assembly uses those validated
before returning raw catalog sources. Internal engine assembly uses those validated
catalogs as the maintained lowest-precedence profile source.
The overlay repository consults the next repository only when the

View File

@@ -55,15 +55,15 @@ packages or participate in internal assembly.
The internal engine assembles one immutable backend registry, one capacity manager,
the internal repositories, renderer, validator, outbound client, and use-case
runner while translating public values and errors at the library boundary. The
registry contains validated maintained definitions plus engine-scoped consumer
additions.
The facade constructs the capacity manager from the registry's immutable
runner. The registry contains validated maintained definitions plus engine-scoped
consumer additions. The root facade translates public values and errors at the
library boundary.
The internal engine constructs the capacity manager from the registry's immutable
policy snapshot, wraps the selected built-in or injected model client, and
supplies bounded admission to the runner. The defaults and renderer depend on
the domain model. Prompt-definition and profile repositories use the domain
model, file catalog, and YAML decoder. The catalog adapter validates imported
immutable maintained data before root assembly supplies it to the profile
immutable maintained data before internal engine assembly supplies it to the profile
package. Artifact reading uses the domain
model and application-neutral defaults. Validation uses the domain model, file
catalog, and JSON Schema implementation. The model client uses the domain

View File

@@ -229,6 +229,17 @@ capacity managers, catalogs, or runners. Public options still have their existin
names, signatures, ordering, capture, and error behavior. No transitional state
mapping or duplicate source-assembly path remains.
### Completion
- [x] Wrapped the root option state around internal engine options with private
construction state.
- [x] Moved filesystem and file source setup, source replacement, profile
repository construction, validator construction, and backend accumulation
into internal option methods.
- [x] Kept ordered public option application, boundary conversion, collaborator
adaptation, and error translation in the root facade.
- [x] Updated current architecture and internal ownership documentation.
## Stage 5: Guard The Public Boundary And Dependency Direction
Purpose: protect the intended architecture without freezing private layout.

129
engine.go
View File

@@ -6,19 +6,11 @@ import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
)
// ErrInvalidConfig identifies invalid engine construction, including missing
@@ -134,20 +126,7 @@ func (f optionFunc) apply(options *engineOptions) error {
}
type engineOptions struct {
llmClient llm.Client
artifactReader artifactadapter.Reader
promptDefs promptdef.Repository
profiles profile.Repository
fallbackProfiles profile.Repository
memoryProfiles profile.Repository
backends []domain.Backend
validator validate.Validator
promptSource bool
profileSource bool
fallbackProfileSource bool
memorySource bool
validatorSource bool
artifactSource bool
internal internalengine.Options
}
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
@@ -163,7 +142,7 @@ func WithLLMClient(client LLMClient) Option {
if client == nil {
return ErrInvalidConfig
}
options.llmClient = publicLLMClientAdapter{client: client}
options.internal.SetLLMClient(publicLLMClientAdapter{client: client})
return nil
})
}
@@ -178,8 +157,7 @@ func WithArtifactReader(reader ArtifactReader) Option {
if reader == nil {
return ErrInvalidConfig
}
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
options.artifactSource = true
options.internal.SetArtifactReader(publicArtifactReaderAdapter{reader: reader})
return nil
})
}
@@ -193,15 +171,7 @@ func WithArtifactReader(reader ArtifactReader) Option {
// prompt-source options.
func WithPromptFS(fsys fs.FS, root string) Option {
return optionFunc(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
return options.internal.SetPromptFS(fsys, root)
})
}
@@ -212,13 +182,7 @@ func WithPromptFS(fsys fs.FS, root string) Option {
// This option replaces Config.PromptDir and earlier prompt-source options.
func WithPromptFile(path string) Option {
return optionFunc(func(options *engineOptions) error {
fsys, root, err := fileSource(path)
if err != nil {
return err
}
options.promptDefs = promptdef.NewFileRepository(fsys, root, filepath.Dir(path))
options.promptSource = true
return nil
return options.internal.SetPromptFile(path)
})
}
@@ -232,15 +196,7 @@ func WithPromptFile(path string) Option {
// FS profile-source options, but remains below WithProfiles in precedence.
func WithProfileFS(fsys fs.FS, root string) Option {
return optionFunc(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
return options.internal.SetProfileFS(fsys, root)
})
}
@@ -254,13 +210,7 @@ func WithProfileFS(fsys fs.FS, root string) Option {
// precedence.
func WithProfileFile(path string) Option {
return optionFunc(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
return options.internal.SetProfileFile(path)
})
}
@@ -285,15 +235,7 @@ func WithProfileFile(path string) Option {
// failover.
func WithFallbackProfileFS(fsys fs.FS, root string) Option {
return optionFunc(func(options *engineOptions) error {
if fsys == nil {
return ErrInvalidConfig
}
if strings.TrimSpace(root) == "" {
return ErrInvalidConfig
}
options.fallbackProfiles = profile.NewFSRepository(fsys, root)
options.fallbackProfileSource = true
return nil
return options.internal.SetFallbackProfileFS(fsys, root)
})
}
@@ -312,13 +254,7 @@ func WithProfiles(profiles ...Profile) Option {
for index, publicProfile := range profiles {
definitions[index] = toDomainProfile(publicProfile)
}
repo, err := profile.NewMemoryRepository(definitions)
if err != nil {
return err
}
options.memoryProfiles = repo
options.memorySource = true
return nil
return options.internal.SetMemoryProfiles(definitions)
})
}
@@ -330,15 +266,7 @@ func WithProfiles(profiles ...Profile) Option {
// replaces Config.SchemaDir and earlier schema-source options.
func WithSchemaFS(fsys fs.FS, root string) Option {
return optionFunc(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
return options.internal.SetSchemaFS(fsys, root)
})
}
@@ -349,13 +277,7 @@ func WithSchemaFS(fsys fs.FS, root string) Option {
// replaces Config.SchemaDir and earlier schema-source options.
func WithSchemaFile(path string) Option {
return optionFunc(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
return options.internal.SetSchemaFile(path)
})
}
@@ -384,15 +306,7 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
assembled, err := internalengine.New(
internalengine.Config{PromptDir: cfg.PromptDir, ProfileDir: cfg.ProfileDir, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient},
internalengine.Options{
LLMClient: options.llmClient, ArtifactReader: options.artifactReader,
PromptDefinitions: options.promptDefs, Profiles: options.profiles,
FallbackProfiles: options.fallbackProfiles, MemoryProfiles: options.memoryProfiles,
Backends: options.backends, Validator: options.validator,
PromptSource: options.promptSource, ProfileSource: options.profileSource,
FallbackProfileSource: options.fallbackProfileSource, MemorySource: options.memorySource,
ValidatorSource: options.validatorSource, ArtifactSource: options.artifactSource,
},
options.internal,
)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
@@ -400,25 +314,6 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
return &Engine{internal: assembled}, nil
}
func fileSource(name string) (fs.FS, string, error) {
if strings.TrimSpace(name) == "" {
return nil, "", ErrInvalidConfig
}
dir := filepath.Dir(name)
base := filepath.Base(name)
if base == "." || base == string(filepath.Separator) {
return nil, "", ErrInvalidConfig
}
info, err := os.Stat(name)
if err != nil {
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, name, err)
}
if info.IsDir() {
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, name)
}
return os.DirFS(dir), filepath.ToSlash(base), nil
}
// InspectPrompt resolves one explicit prompt definition without selecting a
// profile or starting execution work.
//

View File

@@ -4,7 +4,10 @@ package engine
import (
"context"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -33,23 +36,118 @@ type Config struct {
HTTPClient *http.Client
}
// Options carries collaborators prepared by the public facade during the
// transitional composition split.
// Options collects construction choices from the public facade.
type Options struct {
LLMClient llm.Client
ArtifactReader artifact.Reader
PromptDefinitions promptdef.Repository
Profiles profile.Repository
FallbackProfiles profile.Repository
MemoryProfiles profile.Repository
Backends []domain.Backend
Validator validate.Validator
PromptSource bool
ProfileSource bool
FallbackProfileSource bool
MemorySource bool
ValidatorSource bool
ArtifactSource bool
llmClient llm.Client
artifactReader artifact.Reader
promptDefinitions promptdef.Repository
profiles profile.Repository
fallbackProfiles profile.Repository
memoryProfiles profile.Repository
backends []domain.Backend
validator validate.Validator
promptSource bool
profileSource bool
fallbackProfileSource bool
memorySource bool
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
}
// SetPromptFS selects a prompt source rooted in fsys.
func (o *Options) SetPromptFS(fsys fs.FS, root string) error {
if err := validateSourceFS(fsys, root); err != nil {
return err
}
o.promptDefinitions = promptdef.NewFSRepository(fsys, root)
o.promptSource = true
return nil
}
// SetPromptFile selects a single prompt file.
func (o *Options) SetPromptFile(path string) error {
fsys, root, directory, err := fileSource(path)
if err != nil {
return err
}
o.promptDefinitions = promptdef.NewFileRepository(fsys, root, directory)
o.promptSource = true
return nil
}
// SetProfileFS selects an ordinary profile source rooted in fsys.
func (o *Options) SetProfileFS(fsys fs.FS, root string) error {
if err := validateSourceFS(fsys, root); err != nil {
return err
}
o.profiles = profile.NewFSRepository(fsys, root)
o.profileSource = true
return nil
}
// SetProfileFile selects a single ordinary profile file.
func (o *Options) SetProfileFile(path string) error {
fsys, root, _, err := fileSource(path)
if err != nil {
return err
}
o.profiles = profile.NewFSRepository(fsys, root)
o.profileSource = true
return nil
}
// SetFallbackProfileFS selects a fallback profile source rooted in fsys.
func (o *Options) SetFallbackProfileFS(fsys fs.FS, root string) error {
if err := validateSourceFS(fsys, root); err != nil {
return err
}
o.fallbackProfiles = profile.NewFSRepository(fsys, root)
o.fallbackProfileSource = true
return nil
}
// SetMemoryProfiles validates and stores application-owned profile definitions.
func (o *Options) SetMemoryProfiles(definitions []domain.ExecutionProfile) error {
repository, err := profile.NewMemoryRepository(definitions)
if err != nil {
return err
}
o.memoryProfiles = repository
o.memorySource = true
return nil
}
// AddBackend appends one backend registration.
func (o *Options) AddBackend(backend domain.Backend) { o.backends = append(o.backends, backend) }
// 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.
@@ -59,8 +157,8 @@ type Engine struct {
// New assembles an engine from facade-selected configuration and collaborators.
func New(cfg Config, options Options) (*Engine, error) {
promptDefinitions := options.PromptDefinitions
if !options.PromptSource {
promptDefinitions := options.promptDefinitions
if !options.promptSource {
if strings.TrimSpace(cfg.PromptDir) == "" {
return nil, fmt.Errorf("prompt directory is required")
}
@@ -76,7 +174,7 @@ func New(cfg Config, options Options) (*Engine, error) {
}
profiles := newProfileRepository(cfg.ProfileDir, options, maintained.Profiles)
registry, err := backend.NewRegistry(maintained.Backends, options.Backends)
registry, err := backend.NewRegistry(maintained.Backends, options.backends)
if err != nil {
return nil, fmt.Errorf("failed to construct backend registry: %w", err)
}
@@ -85,15 +183,15 @@ func New(cfg Config, options Options) (*Engine, error) {
return nil, fmt.Errorf("failed to construct backend capacity manager: %w", err)
}
validator := options.Validator
if !options.ValidatorSource {
validator := options.validator
if !options.validatorSource {
schemaDir := cfg.SchemaDir
if strings.TrimSpace(schemaDir) == "" {
schemaDir = defaults.SchemaDirDefault
}
validator = validate.NewStandardValidator(schemaDir)
}
client := options.LLMClient
client := options.llmClient
if client == nil {
client, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
if err != nil {
@@ -101,8 +199,8 @@ func New(cfg Config, options Options) (*Engine, error) {
}
}
client = capacity.NewClient(manager, client)
reader := options.ArtifactReader
if !options.ArtifactSource {
reader := options.artifactReader
if !options.artifactSource {
reader = artifact.NewCompositeReader()
}
@@ -111,20 +209,49 @@ func New(cfg Config, options Options) (*Engine, error) {
func newProfileRepository(profileDir string, options Options, maintained profile.Repository) profile.Repository {
repository := maintained
if options.FallbackProfileSource {
repository = profile.NewOverlayRepository(options.FallbackProfiles, repository)
if options.fallbackProfileSource {
repository = profile.NewOverlayRepository(options.fallbackProfiles, repository)
}
if options.ProfileSource {
repository = profile.NewOverlayRepository(options.Profiles, repository)
if options.profileSource {
repository = profile.NewOverlayRepository(options.profiles, repository)
} else if strings.TrimSpace(profileDir) != "" {
repository = profile.NewOverlayRepository(profile.NewFilesystemRepository(profileDir), repository)
}
if options.MemorySource {
repository = profile.NewOverlayRepository(options.MemoryProfiles, repository)
if options.memorySource {
repository = profile.NewOverlayRepository(options.memoryProfiles, repository)
}
return profile.NewResolvingRepository(repository)
}
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, version string) (*domain.PromptInspection, error) {
return e.runner.InspectPrompt(ctx, id, version)
}