Refactor: split prompt definition from execution settings and migrate run contracts to prompt_* + execution_target
This commit is contained in:
@@ -13,9 +13,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProfileNotFound = errors.New("prompt profile not found")
|
||||
ErrProfileNotFound = errors.New("prompt definition not found")
|
||||
ErrInvalidYAML = errors.New("invalid YAML format")
|
||||
ErrInvalidProfile = errors.New("invalid profile configuration")
|
||||
ErrInvalidProfile = errors.New("invalid prompt definition configuration")
|
||||
)
|
||||
|
||||
type filesystemRepository struct {
|
||||
@@ -26,9 +26,9 @@ func NewFilesystemRepository(dir string) Repository {
|
||||
return &filesystemRepository{dir: dir}
|
||||
}
|
||||
|
||||
func (r *filesystemRepository) GetProfile(ctx context.Context, id string, version string) (*domain.PromptProfile, error) {
|
||||
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile)
|
||||
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidProfile)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(r.dir)
|
||||
@@ -53,7 +53,7 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string, versio
|
||||
return nil, fmt.Errorf("failed to read profile file %s: %w", file.Name(), err)
|
||||
}
|
||||
|
||||
var prof domain.PromptProfile
|
||||
var prof domain.PromptDefinition
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(&prof); err != nil {
|
||||
@@ -77,22 +77,33 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string, versio
|
||||
return nil, ErrProfileNotFound
|
||||
}
|
||||
|
||||
func validateProfile(p *domain.PromptProfile) error {
|
||||
func validateProfile(p *domain.PromptDefinition) error {
|
||||
if p.ID == "" {
|
||||
return errors.New("profile id is required")
|
||||
return errors.New("prompt id is required")
|
||||
}
|
||||
if p.Version == "" {
|
||||
return errors.New("profile version is required")
|
||||
return errors.New("prompt version is required")
|
||||
}
|
||||
if len(p.Templates) == 0 {
|
||||
return errors.New("at least one prompt template message is required")
|
||||
}
|
||||
if len(p.Inputs) == 0 {
|
||||
return errors.New("at least one prompt input is required")
|
||||
}
|
||||
for i, input := range p.Inputs {
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return fmt.Errorf("input %d has empty name", i)
|
||||
}
|
||||
}
|
||||
for i, t := range p.Templates {
|
||||
if !isValidMessageRole(t.Role) {
|
||||
return fmt.Errorf("template message %d has invalid role %q", i, t.Role)
|
||||
}
|
||||
if t.Content == "" {
|
||||
return fmt.Errorf("template message %d is missing content", i)
|
||||
if strings.TrimSpace(t.Content) == "" && strings.TrimSpace(t.ContentFile) == "" {
|
||||
return fmt.Errorf("template message %d must provide content or content_file", i)
|
||||
}
|
||||
if strings.TrimSpace(t.Content) != "" && strings.TrimSpace(t.ContentFile) != "" {
|
||||
return fmt.Errorf("template message %d cannot set both content and content_file", i)
|
||||
}
|
||||
}
|
||||
if !isValidOutputFormat(p.OutputFormat) {
|
||||
@@ -107,17 +118,9 @@ func validateProfile(p *domain.PromptProfile) error {
|
||||
if p.Validation.ValidationMode == domain.ValidationJSONSchema && strings.TrimSpace(p.Validation.SchemaPath) == "" {
|
||||
return errors.New("validation.schema_path is required when validation_mode is json_schema")
|
||||
}
|
||||
if p.ModelDefaults.TimeoutSeconds < 0 {
|
||||
return errors.New("model_defaults.timeout_seconds must be greater than or equal to 0")
|
||||
}
|
||||
if p.Validation.Format != "" && p.Validation.Format != p.OutputFormat {
|
||||
return fmt.Errorf("validation format %q does not match output format %q", p.Validation.Format, p.OutputFormat)
|
||||
}
|
||||
for i, input := range p.ExpectedInputs {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return fmt.Errorf("expected input %d has empty name", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||
)
|
||||
|
||||
// Repository handles loading and storing prompt profiles.
|
||||
// Repository is a transitional prompt-definition repository.
|
||||
// It currently lives in internal/profile until package responsibilities
|
||||
// are split in a follow-up refactor.
|
||||
type Repository interface {
|
||||
GetProfile(ctx context.Context, id string, version string) (*domain.PromptProfile, error)
|
||||
GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||
)
|
||||
|
||||
func TestFilesystemRepository_GetProfile(t *testing.T) {
|
||||
func TestFilesystemRepository_GetPromptDefinition(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "profile_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -38,19 +38,19 @@ func TestFilesystemRepository_GetProfile(t *testing.T) {
|
||||
repo := NewFilesystemRepository(tmpDir)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("valid profile", func(t *testing.T) {
|
||||
p, err := repo.GetProfile(ctx, "test-profile", "")
|
||||
t.Run("valid prompt definition", func(t *testing.T) {
|
||||
p, err := repo.GetPromptDefinition(ctx, "test-profile", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if p == nil || p.ID != "test-profile" {
|
||||
t.Errorf("expected profile test-profile, got %v", p)
|
||||
t.Errorf("expected prompt definition test-profile, got %v", p)
|
||||
}
|
||||
if p.Version != "1.0.0" {
|
||||
t.Fatalf("expected version 1.0.0, got %q", p.Version)
|
||||
}
|
||||
if len(p.ExpectedInputs) != 2 || p.ExpectedInputs[0] != "transcript" || p.ExpectedInputs[1] != "glossary" {
|
||||
t.Fatalf("unexpected expected_inputs: %#v", p.ExpectedInputs)
|
||||
if len(p.Inputs) != 2 || p.Inputs[0].Name != "transcript" || p.Inputs[1].Name != "glossary" {
|
||||
t.Fatalf("unexpected inputs: %#v", p.Inputs)
|
||||
}
|
||||
if len(p.Templates) != 2 {
|
||||
t.Fatalf("expected 2 templates, got %d", len(p.Templates))
|
||||
@@ -64,48 +64,41 @@ func TestFilesystemRepository_GetProfile(t *testing.T) {
|
||||
if p.Validation.ValidationMode != domain.ValidationBasic {
|
||||
t.Fatalf("expected validation mode basic, got %q", p.Validation.ValidationMode)
|
||||
}
|
||||
if p.ModelDefaults.TimeoutSeconds != 120 {
|
||||
t.Fatalf("expected timeout_seconds 120, got %d", p.ModelDefaults.TimeoutSeconds)
|
||||
if p.DefaultProfile != "test-exec" {
|
||||
t.Fatalf("expected default profile test-exec, got %q", p.DefaultProfile)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid YAML", func(t *testing.T) {
|
||||
_, err := repo.GetProfile(ctx, "invalid_yaml", "")
|
||||
_, err := repo.GetPromptDefinition(ctx, "invalid_yaml", "")
|
||||
if !errors.Is(err, ErrInvalidYAML) {
|
||||
t.Errorf("expected ErrInvalidYAML, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing ID", func(t *testing.T) {
|
||||
_, err := repo.GetProfile(ctx, "missing-id", "")
|
||||
_, err := repo.GetPromptDefinition(ctx, "missing-id", "")
|
||||
if !errors.Is(err, ErrProfileNotFound) {
|
||||
t.Errorf("expected ErrProfileNotFound for profile with missing ID, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no templates", func(t *testing.T) {
|
||||
_, err := repo.GetProfile(ctx, "no-templates", "")
|
||||
_, err := repo.GetPromptDefinition(ctx, "no-templates", "")
|
||||
if !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Errorf("expected ErrInvalidProfile for profile with no templates, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json schema mode missing schema path", func(t *testing.T) {
|
||||
_, err := repo.GetProfile(ctx, "json-schema-missing-path", "")
|
||||
_, err := repo.GetPromptDefinition(ctx, "json-schema-missing-path", "")
|
||||
if !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Errorf("expected ErrInvalidProfile for json_schema profile without schema_path, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative timeout seconds", func(t *testing.T) {
|
||||
_, err := repo.GetProfile(ctx, "negative-timeout", "")
|
||||
if !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Errorf("expected ErrInvalidProfile for negative timeout_seconds, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile not found", func(t *testing.T) {
|
||||
_, err := repo.GetProfile(ctx, "unknown", "")
|
||||
t.Run("prompt definition not found", func(t *testing.T) {
|
||||
_, err := repo.GetPromptDefinition(ctx, "unknown", "")
|
||||
if !errors.Is(err, ErrProfileNotFound) {
|
||||
t.Errorf("expected ErrProfileNotFound, got %v", err)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
id: json-schema-missing-path
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
templates:
|
||||
- role: user
|
||||
content: "Return JSON"
|
||||
|
||||
3
internal/profile/testdata/missing_id.yaml
vendored
3
internal/profile/testdata/missing_id.yaml
vendored
@@ -1,5 +1,8 @@
|
||||
version: 1.0.0
|
||||
description: Missing ID
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
templates:
|
||||
- role: system
|
||||
content: Hello
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
id: negative-timeout
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
templates:
|
||||
- role: user
|
||||
content: "Say hi"
|
||||
model_defaults:
|
||||
timeout_seconds: -1
|
||||
output_format: text
|
||||
validation:
|
||||
validation_mode: none
|
||||
|
||||
3
internal/profile/testdata/no_templates.yaml
vendored
3
internal/profile/testdata/no_templates.yaml
vendored
@@ -1,5 +1,8 @@
|
||||
id: no-templates
|
||||
version: 1.0.0
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
templates: []
|
||||
output_format: text
|
||||
validation:
|
||||
|
||||
17
internal/profile/testdata/valid.yaml
vendored
17
internal/profile/testdata/valid.yaml
vendored
@@ -1,18 +1,19 @@
|
||||
id: test-profile
|
||||
version: "1.0.0"
|
||||
description: A valid test profile
|
||||
expected_inputs:
|
||||
- transcript
|
||||
- glossary
|
||||
default_profile: test-exec
|
||||
description: A valid test prompt definition
|
||||
inputs:
|
||||
- name: transcript
|
||||
required: true
|
||||
content_type: text/markdown
|
||||
- name: glossary
|
||||
required: false
|
||||
content_type: text/yaml
|
||||
templates:
|
||||
- role: system
|
||||
content: "You are a helpful assistant."
|
||||
- role: user
|
||||
content: 'Analyze this: {{input "transcript"}}'
|
||||
model_defaults:
|
||||
model: gpt-4o
|
||||
temperature: 0.7
|
||||
timeout_seconds: 120
|
||||
output_format: markdown
|
||||
validation:
|
||||
validation_mode: basic
|
||||
|
||||
Reference in New Issue
Block a user