Bugfixes and improved alignment with architecture blueprint

This commit is contained in:
2026-05-04 20:36:12 -05:00
parent c07320f9d0
commit fa070a296d
10 changed files with 158 additions and 34 deletions

View File

@@ -1,3 +1,9 @@
# scriptorium # scriptorium
Scriptorium is a a prompt-profile execution engine written in Go. Scriptorium is a prompt-profile execution engine written in Go.
Current implementation scope:
- domain model and core interfaces
- YAML-backed prompt profile loading
- inline and file artifact reading
- provider-neutral prompt rendering

View File

@@ -36,6 +36,12 @@ func NewCompositeReader() Reader {
} }
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
switch ref.Type { switch ref.Type {
case domain.ArtifactRefInline: case domain.ArtifactRefInline:
return c.inlineReader.Read(ctx, ref) return c.inlineReader.Read(ctx, ref)
@@ -49,22 +55,35 @@ func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*do
type inlineReader struct{} type inlineReader struct{}
func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if ref.Body == "" { if ref.Body == "" {
return nil, ErrMissingInlineBody return nil, ErrMissingInlineBody
} }
body := []byte(ref.Body) body := []byte(ref.Body)
return &domain.Artifact{ return &domain.Artifact{
Body: body, ContentType: "text/plain",
Size: int64(len(body)), Body: body,
Hash: fmt.Sprintf("%x", sha256.Sum256(body)), Size: int64(len(body)),
URI: ref.URI, Hash: fmt.Sprintf("%x", sha256.Sum256(body)),
URI: ref.URI,
}, nil }, nil
} }
type fileReader struct{} type fileReader struct{}
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if ref.URI == "" { if ref.URI == "" {
return nil, ErrMissingFilePath return nil, ErrMissingFilePath
} }

View File

@@ -2,6 +2,7 @@ package artifact
import ( import (
"context" "context"
"errors"
"os" "os"
"testing" "testing"
@@ -24,8 +25,11 @@ func TestCompositeReader_Read(t *testing.T) {
if string(art.Body) != "hello world" { if string(art.Body) != "hello world" {
t.Errorf("expected 'hello world', got %s", string(art.Body)) t.Errorf("expected 'hello world', got %s", string(art.Body))
} }
if art.Hash == "" { if art.ContentType != "text/plain" {
t.Error("expected hash to be computed") t.Errorf("expected text/plain content type, got %q", art.ContentType)
}
if art.Hash != "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" {
t.Errorf("unexpected hash: %s", art.Hash)
} }
}) })
@@ -35,7 +39,7 @@ func TestCompositeReader_Read(t *testing.T) {
Body: "", Body: "",
} }
_, err := reader.Read(ctx, ref) _, err := reader.Read(ctx, ref)
if err == nil || err != ErrMissingInlineBody { if !errors.Is(err, ErrMissingInlineBody) {
t.Errorf("expected ErrMissingInlineBody, got %v", err) t.Errorf("expected ErrMissingInlineBody, got %v", err)
} }
}) })
@@ -46,7 +50,7 @@ func TestCompositeReader_Read(t *testing.T) {
URI: "s3://bucket/key", URI: "s3://bucket/key",
} }
_, err := reader.Read(ctx, ref) _, err := reader.Read(ctx, ref)
if err == nil { if !errors.Is(err, ErrUnsupportedRefType) {
t.Error("expected error for unsupported type") t.Error("expected error for unsupported type")
} }
}) })
@@ -83,8 +87,8 @@ func TestFileReader_Read(t *testing.T) {
if art.Name == "" { if art.Name == "" {
t.Error("expected name to be inferred from filename") t.Error("expected name to be inferred from filename")
} }
if art.Hash == "" { if art.Hash != "60f5237ed4049f0382661ef009d2bc42e48c3ceb3edb6600f7024e7ab3b838f3" {
t.Error("expected hash to be computed") t.Errorf("unexpected hash: %s", art.Hash)
} }
}) })
@@ -94,7 +98,7 @@ func TestFileReader_Read(t *testing.T) {
URI: "", URI: "",
} }
_, err := reader.Read(ctx, ref) _, err := reader.Read(ctx, ref)
if err == nil || err != ErrMissingFilePath { if !errors.Is(err, ErrMissingFilePath) {
t.Errorf("expected ErrMissingFilePath, got %v", err) t.Errorf("expected ErrMissingFilePath, got %v", err)
} }
}) })

View File

@@ -43,11 +43,13 @@ const (
// RunRequest represents a request to generate a single artifact. // RunRequest represents a request to generate a single artifact.
type RunRequest struct { type RunRequest struct {
ProfileID string ProfileID string
Inputs map[string]ArtifactRef ProfileVersion string
Vars map[string]string Inputs map[string]ArtifactRef
Model *ModelTarget Vars map[string]string
Metadata map[string]string Model *ModelTarget
Validation *OutputContract
Metadata map[string]string
} }
// RunResult represents the complete result of a prompt execution run. // RunResult represents the complete result of a prompt execution run.

View File

@@ -1,6 +1,7 @@
package profile package profile
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -26,12 +27,22 @@ func NewFilesystemRepository(dir string) Repository {
} }
func (r *filesystemRepository) GetProfile(ctx context.Context, id string, version string) (*domain.PromptProfile, error) { func (r *filesystemRepository) GetProfile(ctx context.Context, id string, version string) (*domain.PromptProfile, error) {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile)
}
files, err := os.ReadDir(r.dir) files, err := os.ReadDir(r.dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read profile directory: %w", err) return nil, fmt.Errorf("failed to read profile directory: %w", err)
} }
for _, file := range files { for _, file := range files {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if file.IsDir() || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) { if file.IsDir() || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) {
continue continue
} }
@@ -43,8 +54,10 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string, versio
} }
var prof domain.PromptProfile var prof domain.PromptProfile
if err := yaml.Unmarshal(data, &prof); err != nil { decoder := yaml.NewDecoder(bytes.NewReader(data))
if strings.Contains(file.Name(), id) { decoder.KnownFields(true)
if err := decoder.Decode(&prof); err != nil {
if strings.TrimSuffix(strings.TrimSuffix(file.Name(), ".yaml"), ".yml") == id {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, file.Name(), err) return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, file.Name(), err)
} }
continue continue
@@ -75,8 +88,8 @@ func validateProfile(p *domain.PromptProfile) error {
return errors.New("at least one prompt template message is required") return errors.New("at least one prompt template message is required")
} }
for i, t := range p.Templates { for i, t := range p.Templates {
if t.Role == "" { if !isValidMessageRole(t.Role) {
return fmt.Errorf("template message %d is missing role", i) return fmt.Errorf("template message %d has invalid role %q", i, t.Role)
} }
if t.Content == "" { if t.Content == "" {
return fmt.Errorf("template message %d is missing content", i) return fmt.Errorf("template message %d is missing content", i)
@@ -88,6 +101,15 @@ func validateProfile(p *domain.PromptProfile) error {
if !isValidValidationMode(p.Validation.ValidationMode) { if !isValidValidationMode(p.Validation.ValidationMode) {
return fmt.Errorf("invalid validation mode: %s", p.Validation.ValidationMode) return fmt.Errorf("invalid validation mode: %s", p.Validation.ValidationMode)
} }
if p.Validation.RepairAttempts < 0 {
return errors.New("validation.repair_attempts must be greater than or equal to 0")
}
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.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 { for i, input := range p.ExpectedInputs {
if strings.TrimSpace(input) == "" { if strings.TrimSpace(input) == "" {
return fmt.Errorf("expected input %d has empty name", i) return fmt.Errorf("expected input %d has empty name", i)
@@ -111,3 +133,11 @@ func isValidValidationMode(m domain.ValidationMode) bool {
} }
return false return false
} }
func isValidMessageRole(role string) bool {
switch role {
case "system", "user", "assistant", "developer":
return true
}
return false
}

View File

@@ -6,6 +6,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
) )
func TestFilesystemRepository_GetProfile(t *testing.T) { func TestFilesystemRepository_GetProfile(t *testing.T) {
@@ -44,6 +46,24 @@ func TestFilesystemRepository_GetProfile(t *testing.T) {
if p == nil || p.ID != "test-profile" { if p == nil || p.ID != "test-profile" {
t.Errorf("expected profile test-profile, got %v", p) t.Errorf("expected profile 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.Templates) != 2 {
t.Fatalf("expected 2 templates, got %d", len(p.Templates))
}
if p.Templates[0].Role != "system" || p.Templates[1].Role != "user" {
t.Fatalf("unexpected template roles: %#v", p.Templates)
}
if p.OutputFormat != domain.FormatMarkdown {
t.Fatalf("expected output format markdown, got %q", p.OutputFormat)
}
if p.Validation.ValidationMode != domain.ValidationBasic {
t.Fatalf("expected validation mode basic, got %q", p.Validation.ValidationMode)
}
}) })
t.Run("invalid YAML", func(t *testing.T) { t.Run("invalid YAML", func(t *testing.T) {
@@ -67,6 +87,13 @@ func TestFilesystemRepository_GetProfile(t *testing.T) {
} }
}) })
t.Run("json schema mode missing schema path", func(t *testing.T) {
_, err := repo.GetProfile(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("profile not found", func(t *testing.T) { t.Run("profile not found", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "unknown", "") _, err := repo.GetProfile(ctx, "unknown", "")
if !errors.Is(err, ErrProfileNotFound) { if !errors.Is(err, ErrProfileNotFound) {

View File

@@ -0,0 +1,8 @@
id: json-schema-missing-path
version: "1.0.0"
templates:
- role: user
content: "Return JSON"
output_format: json
validation:
validation_mode: json_schema

View File

@@ -1,14 +1,14 @@
id: test-profile id: test-profile
version: 1.0.0 version: "1.0.0"
description: A valid test profile description: A valid test profile
expected_inputs: expected_inputs:
- transcript - transcript
- glossary - glossary
templates: templates:
- role: system - role: system
content: You are a helpful assistant. content: "You are a helpful assistant."
- role: user - role: user
content: Analyze this: {{.transcript}} content: 'Analyze this: {{input "transcript"}}'
model_defaults: model_defaults:
model: gpt-4o model: gpt-4o
temperature: 0.7 temperature: 0.7

View File

@@ -13,6 +13,7 @@ var (
ErrMissingRequiredInput = errors.New("missing required input artifact") ErrMissingRequiredInput = errors.New("missing required input artifact")
ErrUnknownInput = errors.New("referenced unknown input artifact") ErrUnknownInput = errors.New("referenced unknown input artifact")
ErrInvalidTemplate = errors.New("invalid prompt template") ErrInvalidTemplate = errors.New("invalid prompt template")
ErrRenderFailure = errors.New("prompt render failure")
ErrInvalidMessageRole = errors.New("invalid or empty message role") ErrInvalidMessageRole = errors.New("invalid or empty message role")
) )
@@ -23,9 +24,14 @@ func NewGoRenderer() Renderer {
} }
func (r *goRenderer) Render(ctx context.Context, profile *domain.PromptProfile, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) { func (r *goRenderer) Render(ctx context.Context, profile *domain.PromptProfile, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
if profile == nil {
return nil, fmt.Errorf("%w: nil profile", ErrRenderFailure)
}
// 1. Verify required inputs // 1. Verify required inputs
for _, req := range profile.ExpectedInputs { for _, req := range profile.ExpectedInputs {
if _, ok := inputs[req]; !ok { art, ok := inputs[req]
if !ok || art == nil {
return nil, fmt.Errorf("%w: %s", ErrMissingRequiredInput, req) return nil, fmt.Errorf("%w: %s", ErrMissingRequiredInput, req)
} }
} }
@@ -44,19 +50,25 @@ func (r *goRenderer) Render(ctx context.Context, profile *domain.PromptProfile,
var renderedMessages []domain.RenderedMessage var renderedMessages []domain.RenderedMessage
for i, tmplMsg := range profile.Templates { for i, tmplMsg := range profile.Templates {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if tmplMsg.Role == "" { if tmplMsg.Role == "" {
return nil, fmt.Errorf("%w: message %d", ErrInvalidMessageRole, i) return nil, fmt.Errorf("%w: message %d", ErrInvalidMessageRole, i)
} }
// Parse and execute template // Parse and execute template
tmpl, err := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Parse(tmplMsg.Content) tmpl, err := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Option("missingkey=error").Parse(tmplMsg.Content)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: message %d: %v", ErrInvalidTemplate, i, err) return nil, fmt.Errorf("%w: message %d: %v", ErrInvalidTemplate, i, err)
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, vars); err != nil { if err := tmpl.Execute(&buf, vars); err != nil {
return nil, fmt.Errorf("execution failed for message %d: %v", i, err) return nil, fmt.Errorf("%w: message %d: %w", ErrRenderFailure, i, err)
} }
renderedMessages = append(renderedMessages, domain.RenderedMessage{ renderedMessages = append(renderedMessages, domain.RenderedMessage{

View File

@@ -2,6 +2,7 @@ package prompt
import ( import (
"context" "context"
"errors"
"testing" "testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain" "gitea.maximumdirect.net/eric/scriptorium/internal/domain"
@@ -47,7 +48,7 @@ func TestGoRenderer_Render(t *testing.T) {
t.Run("missing required input", func(t *testing.T) { t.Run("missing required input", func(t *testing.T) {
emptyInputs := map[string]*domain.Artifact{} emptyInputs := map[string]*domain.Artifact{}
_, err := renderer.Render(ctx, profile, emptyInputs, vars) _, err := renderer.Render(ctx, profile, emptyInputs, vars)
if err == nil || (err != ErrMissingRequiredInput && err.Error() != "missing required input artifact: transcript") { if !errors.Is(err, ErrMissingRequiredInput) {
t.Errorf("expected ErrMissingRequiredInput, got %v", err) t.Errorf("expected ErrMissingRequiredInput, got %v", err)
} }
}) })
@@ -59,8 +60,11 @@ func TestGoRenderer_Render(t *testing.T) {
}, },
} }
_, err := renderer.Render(ctx, profileUnknown, inputs, vars) _, err := renderer.Render(ctx, profileUnknown, inputs, vars)
if err == nil { if !errors.Is(err, ErrRenderFailure) {
t.Error("expected error for unknown input") t.Errorf("expected ErrRenderFailure, got %v", err)
}
if !errors.Is(err, ErrUnknownInput) {
t.Errorf("expected ErrUnknownInput, got %v", err)
} }
}) })
@@ -71,8 +75,8 @@ func TestGoRenderer_Render(t *testing.T) {
}, },
} }
_, err := renderer.Render(ctx, profileInvalid, inputs, vars) _, err := renderer.Render(ctx, profileInvalid, inputs, vars)
if err == nil { if !errors.Is(err, ErrInvalidTemplate) {
t.Error("expected error for invalid template syntax") t.Errorf("expected ErrInvalidTemplate, got %v", err)
} }
}) })
@@ -83,8 +87,20 @@ func TestGoRenderer_Render(t *testing.T) {
}, },
} }
_, err := renderer.Render(ctx, profileNoRole, inputs, vars) _, err := renderer.Render(ctx, profileNoRole, inputs, vars)
if err == nil { if !errors.Is(err, ErrInvalidMessageRole) {
t.Error("expected error for empty message role") t.Errorf("expected ErrInvalidMessageRole, got %v", err)
}
})
t.Run("missing variable in template", func(t *testing.T) {
profileMissingVar := &domain.PromptProfile{
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "You are {{.missing}}"},
},
}
_, err := renderer.Render(ctx, profileMissingVar, inputs, vars)
if !errors.Is(err, ErrRenderFailure) {
t.Errorf("expected ErrRenderFailure for missing variable, got %v", err)
} }
}) })
} }