Add Promptkit configuration and inspection seams
This commit is contained in:
@@ -145,6 +145,21 @@ source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
|
||||
| `timeout` | `2m` | Must be greater than zero. |
|
||||
| `extra_args` | empty | Optional extra arguments passed to Scriptorium commands. |
|
||||
|
||||
### `promptkit`
|
||||
|
||||
Promptkit configuration prepares the local executor and prompt/profile checks.
|
||||
Scriptorium remains the active generator until the Promptkit execution workflow
|
||||
is enabled.
|
||||
|
||||
| Field | Default | Rules |
|
||||
| --- | --- | --- |
|
||||
| `profile` | empty | Optional explicit execution profile. Otherwise the prompt's declared default is used. |
|
||||
| `profile_file` | empty | Optional external profile file. Cannot be combined with `profile_dir`. |
|
||||
| `profile_dir` | empty | Optional external profile directory. Cannot be combined with `profile_file`. |
|
||||
| `timeout` | `2m` | Must be greater than zero. |
|
||||
| `local.endpoint` | empty | Optional absolute URL for the conventional local backend. A blank endpoint leaves it unregistered. |
|
||||
| `local.concurrency_limit` | `1` | Maximum local backend concurrency. `0` is unlimited; negative values are invalid. |
|
||||
|
||||
### `workspace`
|
||||
|
||||
| Field | Default |
|
||||
|
||||
@@ -23,6 +23,12 @@ protocols, and report definitions belong in [the CLI reference](../cli.md),
|
||||
[the configuration reference](../config.md), [operations](../operations.md),
|
||||
and their focused integration and internal documents.
|
||||
|
||||
`InspectPromptExecution` is a side-effect-free preflight helper for the prompt
|
||||
workflow. It verifies the exact report prompt version, its required YAML input,
|
||||
the generated-text JSON Schema contract, the selected profile, and any required
|
||||
environment credential before collection or persistence begins. It returns only
|
||||
safe project-owned identity and provenance values.
|
||||
|
||||
## Single-Report Workflow
|
||||
|
||||
`GenerateDetailed` first collects weather data, then resolves the requested
|
||||
|
||||
@@ -12,6 +12,11 @@ CLI overrides, obtains the current time, and constructs either an
|
||||
`app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and
|
||||
batch execution to `internal/app`.
|
||||
|
||||
`Runner` also owns a project-owned prompt-executor factory seam. Its production
|
||||
factory maps `promptkit` configuration to the Promptkit adapter, while tests can
|
||||
inject a factory without importing dependency types. Construction is retained as
|
||||
a separate seam until the generation workflow begins using that executor.
|
||||
|
||||
For inspection, it loads configuration, builds the appropriate app inspection
|
||||
request, and writes the returned value. Inspection is read-only; the inspected
|
||||
artifact types and user invocation remain owned by the [CLI reference](../cli.md)
|
||||
|
||||
@@ -108,7 +108,8 @@ func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (p
|
||||
ProfileID: inspection.ProfileID,
|
||||
BackendID: inspection.EffectiveModelParams.BackendID,
|
||||
ModelName: inspection.EffectiveModelParams.Model,
|
||||
CredentialRequired: inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "",
|
||||
CredentialRequired: inspection.APIKeyRequired,
|
||||
APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -323,6 +323,10 @@ api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
|
||||
if err != nil {
|
||||
t.Fatalf("newAdapterForTest(credential) error = %v", err)
|
||||
}
|
||||
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
|
||||
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
|
||||
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
|
||||
}
|
||||
request := testExecuteRequest()
|
||||
request.ProfileID = "credential-profile"
|
||||
result, err := credentialAdapter.Execute(context.Background(), request, nil)
|
||||
|
||||
106
internal/app/prompt_inspection.go
Normal file
106
internal/app/prompt_inspection.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
// PromptInspectionRequest contains the non-executing inputs required to
|
||||
// validate one report's configured prompt and profile.
|
||||
type PromptInspectionRequest struct {
|
||||
Resolved report.Resolved
|
||||
Executor promptexec.Executor
|
||||
Promptkit config.PromptkitConfig
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
// PromptInspectionResult contains only safe identity and provenance from a
|
||||
// prompt/profile inspection.
|
||||
type PromptInspectionResult struct {
|
||||
PromptID string
|
||||
PromptVersion string
|
||||
PromptHash string
|
||||
ProfileID string
|
||||
BackendID string
|
||||
ModelName string
|
||||
}
|
||||
|
||||
// InspectPromptExecution validates the exact prompt and profile needed for a
|
||||
// report before collection, execution, or durable writes begin.
|
||||
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
|
||||
if req.Executor == nil {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
|
||||
}
|
||||
definition := req.Resolved.Definition
|
||||
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
|
||||
}
|
||||
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, promptInspectionError("prompt inspection failed", err)
|
||||
}
|
||||
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
|
||||
}
|
||||
if !validPromptInput(inspection.Inputs) {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
|
||||
}
|
||||
if !validPromptOutput(definition, inspection.Output) {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
|
||||
}
|
||||
profileID := req.Promptkit.Profile
|
||||
if profileID == "" {
|
||||
profileID = inspection.DefaultProfileID
|
||||
}
|
||||
if strings.TrimSpace(profileID) == "" {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
|
||||
}
|
||||
profile, err := req.Executor.InspectProfile(ctx, profileID)
|
||||
if err != nil {
|
||||
return PromptInspectionResult{}, promptInspectionError("profile inspection failed", err)
|
||||
}
|
||||
if profile.ProfileID != profileID {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
|
||||
}
|
||||
if profile.CredentialRequired {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
lookupEnv := req.LookupEnv
|
||||
if lookupEnv == nil {
|
||||
lookupEnv = os.LookupEnv
|
||||
}
|
||||
value, present := lookupEnv(profile.APIKeyEnv)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil)
|
||||
}
|
||||
}
|
||||
return PromptInspectionResult{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptHash: inspection.PromptHash,
|
||||
ProfileID: profile.ProfileID,
|
||||
BackendID: profile.BackendID,
|
||||
ModelName: profile.ModelName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validPromptInput(inputs []promptexec.InputDefinition) bool {
|
||||
return len(inputs) == 1 && inputs[0].Name == "data_package" && inputs[0].Required && inputs[0].ContentType == "application/yaml"
|
||||
}
|
||||
|
||||
func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool {
|
||||
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json"
|
||||
}
|
||||
|
||||
func promptInspectionError(operation string, err error) error {
|
||||
if promptexec.CategoryOf(err) != "" {
|
||||
return err
|
||||
}
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
|
||||
}
|
||||
163
internal/app/prompt_inspection_test.go
Normal file
163
internal/app/prompt_inspection_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{
|
||||
prompt: validPromptInspection(resolved.Definition),
|
||||
profiles: map[string]promptexec.ProfileInspection{
|
||||
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"},
|
||||
"override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"},
|
||||
},
|
||||
}
|
||||
defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution(default) error = %v", err)
|
||||
}
|
||||
if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" {
|
||||
t.Fatalf("default result = %#v", defaultResult)
|
||||
}
|
||||
overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{
|
||||
Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InspectPromptExecution(override) error = %v", err)
|
||||
}
|
||||
if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" {
|
||||
t.Fatalf("override result = %#v", overrideResult)
|
||||
}
|
||||
if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" {
|
||||
t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
basePrompt := validPromptInspection(resolved.Definition)
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt promptexec.PromptInspection
|
||||
profile promptexec.ProfileInspection
|
||||
lookupEnv func(string) (string, bool)
|
||||
wantCategory promptexec.ErrorCategory
|
||||
}{
|
||||
{
|
||||
name: "extra input",
|
||||
prompt: func() promptexec.PromptInspection {
|
||||
value := basePrompt
|
||||
value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"})
|
||||
return value
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "wrong schema",
|
||||
prompt: func() promptexec.PromptInspection {
|
||||
value := basePrompt
|
||||
value.Output.SchemaPath = "unexpected.schema.json"
|
||||
return value
|
||||
}(),
|
||||
wantCategory: promptexec.InvalidConfiguration,
|
||||
},
|
||||
{
|
||||
name: "direct key",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
|
||||
wantCategory: promptexec.MissingCredential,
|
||||
},
|
||||
{
|
||||
name: "missing environment credential",
|
||||
prompt: basePrompt,
|
||||
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
|
||||
lookupEnv: func(string) (string, bool) { return "", false },
|
||||
wantCategory: promptexec.MissingCredential,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
|
||||
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv})
|
||||
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
|
||||
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
|
||||
resolved := inspectionResolved(t)
|
||||
executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")}
|
||||
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
|
||||
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
|
||||
t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err))
|
||||
}
|
||||
if strings.Contains(err.Error(), "resolved-secret-value") {
|
||||
t.Fatalf("inspection error leaks provider value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type inspectionPromptRequest struct {
|
||||
id string
|
||||
version string
|
||||
}
|
||||
|
||||
type inspectionExecutor struct {
|
||||
prompt promptexec.PromptInspection
|
||||
profiles map[string]promptexec.ProfileInspection
|
||||
promptErr error
|
||||
promptRequests []inspectionPromptRequest
|
||||
profileRequests []string
|
||||
}
|
||||
|
||||
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
|
||||
e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version})
|
||||
if e.promptErr != nil {
|
||||
return promptexec.PromptInspection{}, e.promptErr
|
||||
}
|
||||
return e.prompt, nil
|
||||
}
|
||||
|
||||
func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
e.profileRequests = append(e.profileRequests, id)
|
||||
value, ok := e.profiles[id]
|
||||
if !ok {
|
||||
return promptexec.ProfileInspection{}, errors.New("profile missing")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
return nil, errors.New("unexpected execution")
|
||||
}
|
||||
|
||||
func inspectionResolved(t *testing.T) report.Resolved {
|
||||
t.Helper()
|
||||
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
|
||||
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
|
||||
Location: time.UTC,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func validPromptInspection(definition report.Definition) promptexec.PromptInspection {
|
||||
return promptexec.PromptInspection{
|
||||
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
|
||||
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
|
||||
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"},
|
||||
}
|
||||
}
|
||||
59
internal/cli/executor_factory.go
Normal file
59
internal/cli/executor_factory.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
// PromptExecutorConfig is the project-owned construction input for one prompt
|
||||
// executor. It keeps adapter implementation types out of Runner's API.
|
||||
type PromptExecutorConfig struct {
|
||||
Profile string
|
||||
ProfileFile string
|
||||
ProfileDirectory string
|
||||
Timeout time.Duration
|
||||
LocalEndpoint string
|
||||
LocalConcurrencyLimit int
|
||||
}
|
||||
|
||||
// ExecutorFactory constructs one executor for an action.
|
||||
type ExecutorFactory func(PromptExecutorConfig) (promptexec.Executor, error)
|
||||
|
||||
func (r Runner) promptExecutor(cfg config.PromptkitConfig) (promptexec.Executor, error) {
|
||||
factory := r.ExecutorFactory
|
||||
if factory == nil {
|
||||
factory = newPromptkitExecutor
|
||||
}
|
||||
return factory(promptExecutorConfig(cfg))
|
||||
}
|
||||
|
||||
func promptExecutorConfig(cfg config.PromptkitConfig) PromptExecutorConfig {
|
||||
result := PromptExecutorConfig{
|
||||
Profile: cfg.Profile,
|
||||
ProfileFile: cfg.ProfileFile,
|
||||
ProfileDirectory: cfg.ProfileDir,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
if cfg.Local.Endpoint != "" {
|
||||
result.LocalEndpoint = cfg.Local.Endpoint
|
||||
result.LocalConcurrencyLimit = cfg.Local.ConcurrencyLimit
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newPromptkitExecutor(cfg PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
return promptkitadapter.New(promptkitAdapterConfig(cfg))
|
||||
}
|
||||
|
||||
func promptkitAdapterConfig(cfg PromptExecutorConfig) promptkitadapter.Config {
|
||||
return promptkitadapter.Config{
|
||||
ProfileDirectory: cfg.ProfileDirectory,
|
||||
ProfileFile: cfg.ProfileFile,
|
||||
LocalEndpoint: cfg.LocalEndpoint,
|
||||
LocalConcurrencyLimit: cfg.LocalConcurrencyLimit,
|
||||
Timeout: cfg.Timeout,
|
||||
}
|
||||
}
|
||||
78
internal/cli/executor_factory_test.go
Normal file
78
internal/cli/executor_factory_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
func TestRunnerPromptExecutorMapsConfigurationOnce(t *testing.T) {
|
||||
var calls int
|
||||
var received PromptExecutorConfig
|
||||
runner := Runner{ExecutorFactory: func(value PromptExecutorConfig) (promptexec.Executor, error) {
|
||||
calls++
|
||||
received = value
|
||||
return factoryExecutor{}, nil
|
||||
}}
|
||||
executor, err := runner.promptExecutor(config.PromptkitConfig{
|
||||
Profile: "selected-profile",
|
||||
ProfileFile: "/etc/weatherreporter/profile.yml",
|
||||
Timeout: 45 * time.Second,
|
||||
Local: config.PromptkitLocalConfig{
|
||||
Endpoint: "http://127.0.0.1:8080",
|
||||
ConcurrencyLimit: 3,
|
||||
},
|
||||
})
|
||||
if err != nil || executor == nil || calls != 1 {
|
||||
t.Fatalf("executor/error/calls = %#v/%v/%d", executor, err, calls)
|
||||
}
|
||||
want := PromptExecutorConfig{
|
||||
Profile: "selected-profile", ProfileFile: "/etc/weatherreporter/profile.yml", Timeout: 45 * time.Second,
|
||||
LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 3,
|
||||
}
|
||||
if received != want {
|
||||
t.Fatalf("factory config = %#v, want %#v", received, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptExecutorConfigLeavesBlankLocalBackendUnregistered(t *testing.T) {
|
||||
value := promptExecutorConfig(config.PromptkitConfig{
|
||||
Timeout: 2 * time.Minute,
|
||||
Local: config.PromptkitLocalConfig{ConcurrencyLimit: 1},
|
||||
})
|
||||
if value.LocalEndpoint != "" || value.LocalConcurrencyLimit != 0 {
|
||||
t.Fatalf("executor config = %#v, want no local backend", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptkitAdapterConfigMapsExecutorSettings(t *testing.T) {
|
||||
adapterConfig := promptkitAdapterConfig(PromptExecutorConfig{
|
||||
ProfileDirectory: "/etc/weatherreporter/profiles",
|
||||
Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2,
|
||||
})
|
||||
want := promptkitadapter.Config{
|
||||
ProfileDirectory: "/etc/weatherreporter/profiles",
|
||||
Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2,
|
||||
}
|
||||
if adapterConfig != want {
|
||||
t.Fatalf("adapter config = %#v, want %#v", adapterConfig, want)
|
||||
}
|
||||
}
|
||||
|
||||
type factoryExecutor struct{}
|
||||
|
||||
func (factoryExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
|
||||
return promptexec.PromptInspection{}, nil
|
||||
}
|
||||
|
||||
func (factoryExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
|
||||
return promptexec.ProfileInspection{}, nil
|
||||
}
|
||||
|
||||
func (factoryExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -40,7 +40,8 @@ Options:
|
||||
`
|
||||
|
||||
type Runner struct {
|
||||
Clock timeutil.Clock
|
||||
Clock timeutil.Clock
|
||||
ExecutorFactory ExecutorFactory
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
|
||||
|
||||
@@ -28,6 +28,7 @@ type Config struct {
|
||||
Notify NotifyConfig `yaml:"notify"`
|
||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Promptkit PromptkitConfig `yaml:"promptkit"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Dayparts []DaypartConfig `yaml:"dayparts"`
|
||||
RecentChange RecentChangeConfig `yaml:"recent_change"`
|
||||
@@ -89,6 +90,19 @@ type ScriptoriumConfig struct {
|
||||
ExtraArgs []string `yaml:"extra_args"`
|
||||
}
|
||||
|
||||
type PromptkitConfig struct {
|
||||
Profile string `yaml:"profile"`
|
||||
ProfileFile string `yaml:"profile_file"`
|
||||
ProfileDir string `yaml:"profile_dir"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Local PromptkitLocalConfig `yaml:"local"`
|
||||
}
|
||||
|
||||
type PromptkitLocalConfig struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
ConcurrencyLimit int `yaml:"concurrency_limit"`
|
||||
}
|
||||
|
||||
type WorkspaceConfig struct {
|
||||
Root string `yaml:"root"`
|
||||
SnapshotsDir string `yaml:"snapshots_dir"`
|
||||
|
||||
@@ -47,6 +47,12 @@ func Defaults() Config {
|
||||
Binary: "scriptorium",
|
||||
Timeout: 2 * time.Minute,
|
||||
},
|
||||
Promptkit: PromptkitConfig{
|
||||
Timeout: 2 * time.Minute,
|
||||
Local: PromptkitLocalConfig{
|
||||
ConcurrencyLimit: 1,
|
||||
},
|
||||
},
|
||||
Workspace: WorkspaceConfig{
|
||||
Root: "workspace",
|
||||
SnapshotsDir: "snapshots",
|
||||
|
||||
101
internal/config/promptkit_test.go
Normal file
101
internal/config/promptkit_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestPromptkitDefaultsAndYAML(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 {
|
||||
t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit)
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(`
|
||||
promptkit:
|
||||
profile: selected
|
||||
profile_file: /etc/weatherreporter/profile.yml
|
||||
timeout: 45s
|
||||
local:
|
||||
endpoint: http://127.0.0.1:8080
|
||||
concurrency_limit: 0
|
||||
`), &cfg); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if cfg.Promptkit.Profile != "selected" || cfg.Promptkit.ProfileFile != "/etc/weatherreporter/profile.yml" || cfg.Promptkit.Timeout != 45*time.Second || cfg.Promptkit.Local.Endpoint != "http://127.0.0.1:8080" || cfg.Promptkit.Local.ConcurrencyLimit != 0 {
|
||||
t.Fatalf("Promptkit YAML = %#v", cfg.Promptkit)
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePromptkit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*PromptkitConfig)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "profile sources conflict",
|
||||
mutate: func(cfg *PromptkitConfig) {
|
||||
cfg.ProfileFile = "profile.yml"
|
||||
cfg.ProfileDir = "profiles"
|
||||
},
|
||||
wantErr: "profile_file",
|
||||
},
|
||||
{
|
||||
name: "nonpositive timeout",
|
||||
mutate: func(cfg *PromptkitConfig) {
|
||||
cfg.Timeout = 0
|
||||
},
|
||||
wantErr: "timeout",
|
||||
},
|
||||
{
|
||||
name: "invalid local endpoint",
|
||||
mutate: func(cfg *PromptkitConfig) {
|
||||
cfg.Local.Endpoint = "not a URL"
|
||||
},
|
||||
wantErr: "local.endpoint",
|
||||
},
|
||||
{
|
||||
name: "negative local concurrency",
|
||||
mutate: func(cfg *PromptkitConfig) {
|
||||
cfg.Local.ConcurrencyLimit = -1
|
||||
},
|
||||
wantErr: "concurrency_limit",
|
||||
},
|
||||
{
|
||||
name: "unlimited local concurrency",
|
||||
mutate: func(cfg *PromptkitConfig) {
|
||||
cfg.Local.Endpoint = "http://127.0.0.1:8080"
|
||||
cfg.Local.ConcurrencyLimit = 0
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unregistered local backend",
|
||||
mutate: func(cfg *PromptkitConfig) {
|
||||
cfg.Local.Endpoint = ""
|
||||
cfg.Local.ConcurrencyLimit = 1
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := Defaults()
|
||||
test.mutate(&cfg.Promptkit)
|
||||
err := Validate(cfg)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ func Validate(cfg Config) error {
|
||||
if cfg.Scriptorium.Timeout <= 0 {
|
||||
return fmt.Errorf("scriptorium.timeout must be greater than zero")
|
||||
}
|
||||
if err := validatePromptkit(cfg.Promptkit); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Workspace.Root == "" {
|
||||
return fmt.Errorf("workspace.root is required")
|
||||
}
|
||||
@@ -85,6 +88,25 @@ func Validate(cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePromptkit(cfg PromptkitConfig) error {
|
||||
if cfg.ProfileFile != "" && cfg.ProfileDir != "" {
|
||||
return fmt.Errorf("promptkit.profile_file and promptkit.profile_dir cannot both be configured")
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
return fmt.Errorf("promptkit.timeout must be greater than zero")
|
||||
}
|
||||
if cfg.Local.Endpoint != "" {
|
||||
parsed, err := url.Parse(cfg.Local.Endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return fmt.Errorf("promptkit.local.endpoint must be an absolute URL when configured")
|
||||
}
|
||||
}
|
||||
if cfg.Local.ConcurrencyLimit < 0 {
|
||||
return fmt.Errorf("promptkit.local.concurrency_limit must be zero or greater")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDistributorNotify(cfg DistributorNotifyConfig) error {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
|
||||
@@ -57,6 +57,7 @@ type ProfileInspection struct {
|
||||
BackendID string
|
||||
ModelName string
|
||||
CredentialRequired bool
|
||||
APIKeyEnv string
|
||||
}
|
||||
|
||||
// ExecuteRequest selects one exact prompt execution. DataPackage is the exact
|
||||
|
||||
Reference in New Issue
Block a user