Add Promptkit configuration and inspection seams

This commit is contained in:
2026-07-31 04:27:36 +00:00
parent 6064af2295
commit 9a17a8de93
15 changed files with 584 additions and 2 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View 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)
}

View 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"},
}
}

View 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,
}
}

View 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
}

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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",

View 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)
}
})
}
}

View File

@@ -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

View File

@@ -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