Implement the initial framework skeleton

This commit is contained in:
2026-05-04 20:14:31 -05:00
parent a7feb0b097
commit c07320f9d0
18 changed files with 1563 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
package artifact
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"mime"
"os"
"path/filepath"
)
var (
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
ErrMissingInlineBody = errors.New("missing body for inline artifact")
ErrMissingFilePath = errors.New("missing file path for file artifact")
)
// Reader resolves artifact references into actual artifacts.
type Reader interface {
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
}
// CompositeReader routes artifact resolution based on the reference type.
type CompositeReader struct {
inlineReader *inlineReader
fileReader *fileReader
}
func NewCompositeReader() Reader {
return &CompositeReader{
inlineReader: &inlineReader{},
fileReader: &fileReader{},
}
}
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
switch ref.Type {
case domain.ArtifactRefInline:
return c.inlineReader.Read(ctx, ref)
case domain.ArtifactRefFile:
return c.fileReader.Read(ctx, ref)
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedRefType, ref.Type)
}
}
type inlineReader struct{}
func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
if ref.Body == "" {
return nil, ErrMissingInlineBody
}
body := []byte(ref.Body)
return &domain.Artifact{
Body: body,
Size: int64(len(body)),
Hash: fmt.Sprintf("%x", sha256.Sum256(body)),
URI: ref.URI,
}, nil
}
type fileReader struct{}
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
if ref.URI == "" {
return nil, ErrMissingFilePath
}
data, err := os.ReadFile(ref.URI)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", ref.URI, err)
}
contentType := mime.TypeByExtension(filepath.Ext(ref.URI))
if contentType == "" {
contentType = "text/plain" // Default
}
return &domain.Artifact{
Name: filepath.Base(ref.URI),
ContentType: contentType,
Body: data,
URI: ref.URI,
Size: int64(len(data)),
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
}, nil
}

View File

@@ -0,0 +1,101 @@
package artifact
import (
"context"
"os"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestCompositeReader_Read(t *testing.T) {
reader := NewCompositeReader()
ctx := context.Background()
t.Run("inline artifact", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefInline,
Body: "hello world",
}
art, err := reader.Read(ctx, ref)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(art.Body) != "hello world" {
t.Errorf("expected 'hello world', got %s", string(art.Body))
}
if art.Hash == "" {
t.Error("expected hash to be computed")
}
})
t.Run("inline artifact missing body", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefInline,
Body: "",
}
_, err := reader.Read(ctx, ref)
if err == nil || err != ErrMissingInlineBody {
t.Errorf("expected ErrMissingInlineBody, got %v", err)
}
})
t.Run("unsupported ref type", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefS3,
URI: "s3://bucket/key",
}
_, err := reader.Read(ctx, ref)
if err == nil {
t.Error("expected error for unsupported type")
}
})
}
func TestFileReader_Read(t *testing.T) {
content := []byte("test file content")
tmpFile, err := os.CreateTemp("", "artifact_test_*.txt")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(content); err != nil {
t.Fatal(err)
}
tmpFile.Close()
reader := NewCompositeReader()
ctx := context.Background()
t.Run("file artifact loading", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefFile,
URI: tmpFile.Name(),
}
art, err := reader.Read(ctx, ref)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(art.Body) != string(content) {
t.Errorf("expected %s, got %s", string(content), string(art.Body))
}
if art.Name == "" {
t.Error("expected name to be inferred from filename")
}
if art.Hash == "" {
t.Error("expected hash to be computed")
}
})
t.Run("missing file path", func(t *testing.T) {
ref := domain.ArtifactRef{
Type: domain.ArtifactRefFile,
URI: "",
}
_, err := reader.Read(ctx, ref)
if err == nil || err != ErrMissingFilePath {
t.Errorf("expected ErrMissingFilePath, got %v", err)
}
})
}

179
internal/domain/domain.go Normal file
View File

@@ -0,0 +1,179 @@
package domain
import (
"time"
)
// ArtifactRefType defines how an artifact is referenced.
type ArtifactRefType string
const (
ArtifactRefInline ArtifactRefType = "inline"
ArtifactRefFile ArtifactRefType = "file"
ArtifactRefS3 ArtifactRefType = "s3"
)
// OutputFormat defines the desired format of the generated artifact.
type OutputFormat string
const (
FormatText OutputFormat = "text"
FormatMarkdown OutputFormat = "markdown"
FormatJSON OutputFormat = "json"
)
// ValidationMode defines how the output should be validated.
type ValidationMode string
const (
ValidationNone ValidationMode = "none"
ValidationBasic ValidationMode = "basic"
ValidationJSON ValidationMode = "json"
ValidationJSONSchema ValidationMode = "json_schema"
)
// ValidationStatus defines the result of a validation check.
type ValidationStatus string
const (
ValidationPassed ValidationStatus = "passed"
ValidationFailed ValidationStatus = "failed"
ValidationSkipped ValidationStatus = "skipped"
)
// RunRequest represents a request to generate a single artifact.
type RunRequest struct {
ProfileID string
Inputs map[string]ArtifactRef
Vars map[string]string
Model *ModelTarget
Metadata map[string]string
}
// RunResult represents the complete result of a prompt execution run.
type RunResult struct {
Artifact Artifact
RawOutput string
Validation ValidationResult
ProfileID string
ProfileVersion string
ModelName string
Endpoint string
InputHashes map[string]string
PromptHash string
Usage TokenUsage
StartTime time.Time
EndTime time.Time
Error error
}
// ArtifactRef represents a reference to an input artifact.
type ArtifactRef struct {
Type ArtifactRefType
URI string
Body string // Used for inline
}
// Artifact represents the actual loaded content of a reference.
type Artifact struct {
Name string
ContentType string
Body []byte
URI string
Size int64
Hash string
}
// PromptProfile represents a configured prompt execution profile.
type PromptProfile struct {
ID string `yaml:"id"`
Version string `yaml:"version"`
Description string `yaml:"description"`
ExpectedInputs []string `yaml:"expected_inputs"`
Templates []PromptMessageTemplate `yaml:"templates"`
ModelDefaults ModelTarget `yaml:"model_defaults"`
OutputFormat OutputFormat `yaml:"output_format"`
Validation OutputContract `yaml:"validation"`
}
// PromptMessageTemplate defines a template for a chat message.
type PromptMessageTemplate struct {
Role string `yaml:"role"`
Content string `yaml:"content"`
}
// ModelTarget represents the LLM endpoint and configuration.
type ModelTarget struct {
Endpoint string `yaml:"endpoint"`
Model string `yaml:"model"`
Temperature float64 `yaml:"temperature"`
MaxTokens int `yaml:"max_tokens"`
TopP float64 `yaml:"top_p"`
}
// OutputContract defines the requirements for the output artifact.
type OutputContract struct {
Format OutputFormat `yaml:"format"`
ValidationMode ValidationMode `yaml:"validation_mode"`
SchemaPath string `yaml:"schema_path"`
RepairAttempts int `yaml:"repair_attempts"`
}
// RenderedPrompt represents the prompt after template application.
type RenderedPrompt struct {
Messages []RenderedMessage
}
// RenderedMessage is a single message in a rendered prompt.
type RenderedMessage struct {
Role string
Content string
}
// GenerateRequest is the internal request passed to the LLM client.
type GenerateRequest struct {
Prompt RenderedPrompt
Target ModelTarget
}
// GenerateResponse is the response received from the LLM client.
type GenerateResponse struct {
Content string
Usage TokenUsage
}
// TokenUsage tracks token consumption.
type TokenUsage struct {
PromptTokens int
CompletionTokens int
TotalTokens int
}
// ValidationResult represents the outcome of an output validation.
type ValidationResult struct {
Status ValidationStatus
Mode ValidationMode
Errors []string
SchemaPath string
RepairAttempts int
IsValid bool
}
// RunMetadata contains auditing information for a run.
type RunMetadata struct {
RunID string
ProfileID string
ProfileVersion string
ProfileHash string
PromptHash string
InputHashes map[string]string
ModelEndpoint string
ModelName string
Params ModelTarget
Timestamp time.Time
Duration time.Duration
Usage TokenUsage
ValidationMode ValidationMode
ValidationStatus ValidationStatus
RepairAttempts int
}

11
internal/llm/client.go Normal file
View File

@@ -0,0 +1,11 @@
package llm
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Client executes a rendered prompt against an LLM endpoint.
type Client interface {
Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error)
}

View File

@@ -0,0 +1,113 @@
package profile
import (
"context"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gopkg.in/yaml.v3"
"os"
"path/filepath"
"strings"
)
var (
ErrProfileNotFound = errors.New("prompt profile not found")
ErrInvalidYAML = errors.New("invalid YAML format")
ErrInvalidProfile = errors.New("invalid profile configuration")
)
type filesystemRepository struct {
dir string
}
func NewFilesystemRepository(dir string) Repository {
return &filesystemRepository{dir: dir}
}
func (r *filesystemRepository) GetProfile(ctx context.Context, id string, version string) (*domain.PromptProfile, error) {
files, err := os.ReadDir(r.dir)
if err != nil {
return nil, fmt.Errorf("failed to read profile directory: %w", err)
}
for _, file := range files {
if file.IsDir() || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) {
continue
}
fullPath := filepath.Join(r.dir, file.Name())
data, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("failed to read profile file %s: %w", file.Name(), err)
}
var prof domain.PromptProfile
if err := yaml.Unmarshal(data, &prof); err != nil {
if strings.Contains(file.Name(), id) {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, file.Name(), err)
}
continue
}
if prof.ID == id {
if version == "" || prof.Version == version {
if err := validateProfile(&prof); err != nil {
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, file.Name(), err)
}
return &prof, nil
}
}
}
return nil, ErrProfileNotFound
}
func validateProfile(p *domain.PromptProfile) error {
if p.ID == "" {
return errors.New("profile id is required")
}
if p.Version == "" {
return errors.New("profile version is required")
}
if len(p.Templates) == 0 {
return errors.New("at least one prompt template message is required")
}
for i, t := range p.Templates {
if t.Role == "" {
return fmt.Errorf("template message %d is missing role", i)
}
if t.Content == "" {
return fmt.Errorf("template message %d is missing content", i)
}
}
if !isValidOutputFormat(p.OutputFormat) {
return fmt.Errorf("invalid output format: %s", p.OutputFormat)
}
if !isValidValidationMode(p.Validation.ValidationMode) {
return fmt.Errorf("invalid validation mode: %s", p.Validation.ValidationMode)
}
for i, input := range p.ExpectedInputs {
if strings.TrimSpace(input) == "" {
return fmt.Errorf("expected input %d has empty name", i)
}
}
return nil
}
func isValidOutputFormat(f domain.OutputFormat) bool {
switch f {
case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON:
return true
}
return false
}
func isValidValidationMode(m domain.ValidationMode) bool {
switch m {
case domain.ValidationNone, domain.ValidationBasic, domain.ValidationJSON, domain.ValidationJSONSchema:
return true
}
return false
}

View File

@@ -0,0 +1,11 @@
package profile
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Repository handles loading and storing prompt profiles.
type Repository interface {
GetProfile(ctx context.Context, id string, version string) (*domain.PromptProfile, error)
}

View File

@@ -0,0 +1,76 @@
package profile
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
func TestFilesystemRepository_GetProfile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "profile_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
testDataDir := "testdata"
files, err := os.ReadDir(testDataDir)
if err != nil {
t.Fatalf("failed to read testdata: %v", err)
}
for _, f := range files {
src := filepath.Join(testDataDir, f.Name())
dst := filepath.Join(tmpDir, f.Name())
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, data, 0644); err != nil {
t.Fatal(err)
}
}
repo := NewFilesystemRepository(tmpDir)
ctx := context.Background()
t.Run("valid profile", func(t *testing.T) {
p, err := repo.GetProfile(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.Run("invalid YAML", func(t *testing.T) {
_, err := repo.GetProfile(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", "")
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", "")
if !errors.Is(err, ErrInvalidProfile) {
t.Errorf("expected ErrInvalidProfile for profile with no templates, got %v", err)
}
})
t.Run("profile not found", func(t *testing.T) {
_, err := repo.GetProfile(ctx, "unknown", "")
if !errors.Is(err, ErrProfileNotFound) {
t.Errorf("expected ErrProfileNotFound, got %v", err)
}
})
}

View File

@@ -0,0 +1,5 @@
id: invalid-yaml
version: 1.0.0
templates:
- role: system
content: [unclosed bracket

View File

@@ -0,0 +1,8 @@
version: 1.0.0
description: Missing ID
templates:
- role: system
content: Hello
output_format: text
validation:
validation_mode: none

View File

@@ -0,0 +1,6 @@
id: no-templates
version: 1.0.0
templates: []
output_format: text
validation:
validation_mode: none

17
internal/profile/testdata/valid.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
id: test-profile
version: 1.0.0
description: A valid test profile
expected_inputs:
- transcript
- glossary
templates:
- role: system
content: You are a helpful assistant.
- role: user
content: Analyze this: {{.transcript}}
model_defaults:
model: gpt-4o
temperature: 0.7
output_format: markdown
validation:
validation_mode: basic

View File

@@ -0,0 +1,71 @@
package prompt
import (
"bytes"
"context"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"text/template"
)
var (
ErrMissingRequiredInput = errors.New("missing required input artifact")
ErrUnknownInput = errors.New("referenced unknown input artifact")
ErrInvalidTemplate = errors.New("invalid prompt template")
ErrInvalidMessageRole = errors.New("invalid or empty message role")
)
type goRenderer struct{}
func NewGoRenderer() Renderer {
return &goRenderer{}
}
func (r *goRenderer) Render(ctx context.Context, profile *domain.PromptProfile, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
// 1. Verify required inputs
for _, req := range profile.ExpectedInputs {
if _, ok := inputs[req]; !ok {
return nil, fmt.Errorf("%w: %s", ErrMissingRequiredInput, req)
}
}
// 2. Setup template functions
funcs := template.FuncMap{
"input": func(name string) (string, error) {
art, ok := inputs[name]
if !ok {
return "", fmt.Errorf("%w: %s", ErrUnknownInput, name)
}
return string(art.Body), nil
},
}
var renderedMessages []domain.RenderedMessage
for i, tmplMsg := range profile.Templates {
if tmplMsg.Role == "" {
return nil, fmt.Errorf("%w: message %d", ErrInvalidMessageRole, i)
}
// Parse and execute template
tmpl, err := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Parse(tmplMsg.Content)
if err != nil {
return nil, fmt.Errorf("%w: message %d: %v", ErrInvalidTemplate, i, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, vars); err != nil {
return nil, fmt.Errorf("execution failed for message %d: %v", i, err)
}
renderedMessages = append(renderedMessages, domain.RenderedMessage{
Role: tmplMsg.Role,
Content: buf.String(),
})
}
return &domain.RenderedPrompt{
Messages: renderedMessages,
}, nil
}

View File

@@ -0,0 +1,11 @@
package prompt
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Renderer renders prompt templates using named artifacts and variables.
type Renderer interface {
Render(ctx context.Context, profile *domain.PromptProfile, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error)
}

View File

@@ -0,0 +1,90 @@
package prompt
import (
"context"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
func TestGoRenderer_Render(t *testing.T) {
renderer := NewGoRenderer()
ctx := context.Background()
profile := &domain.PromptProfile{
ID: "test-profile",
ExpectedInputs: []string{"transcript"},
Templates: []domain.PromptMessageTemplate{
{Role: "system", Content: "You are a {{.role}}."},
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
},
}
inputs := map[string]*domain.Artifact{
"transcript": {Body: []byte("The quick brown fox.")},
}
vars := map[string]string{
"role": "helpful assistant",
}
t.Run("successful render", func(t *testing.T) {
res, err := renderer.Render(ctx, profile, inputs, vars)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(res.Messages) != 2 {
t.Errorf("expected 2 messages, got %d", len(res.Messages))
}
if res.Messages[0].Content != "You are a helpful assistant." {
t.Errorf("unexpected system message: %s", res.Messages[0].Content)
}
if res.Messages[1].Content != "Analyze this: The quick brown fox." {
t.Errorf("unexpected user message: %s", res.Messages[1].Content)
}
})
t.Run("missing required input", func(t *testing.T) {
emptyInputs := map[string]*domain.Artifact{}
_, err := renderer.Render(ctx, profile, emptyInputs, vars)
if err == nil || (err != ErrMissingRequiredInput && err.Error() != "missing required input artifact: transcript") {
t.Errorf("expected ErrMissingRequiredInput, got %v", err)
}
})
t.Run("unknown input in template", func(t *testing.T) {
profileUnknown := &domain.PromptProfile{
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Hello {{input \"ghost\"}}"},
},
}
_, err := renderer.Render(ctx, profileUnknown, inputs, vars)
if err == nil {
t.Error("expected error for unknown input")
}
})
t.Run("invalid template syntax", func(t *testing.T) {
profileInvalid := &domain.PromptProfile{
Templates: []domain.PromptMessageTemplate{
{Role: "user", Content: "Hello {{.unclosed"},
},
}
_, err := renderer.Render(ctx, profileInvalid, inputs, vars)
if err == nil {
t.Error("expected error for invalid template syntax")
}
})
t.Run("empty message role", func(t *testing.T) {
profileNoRole := &domain.PromptProfile{
Templates: []domain.PromptMessageTemplate{
{Role: "", Content: "Hello"},
},
}
_, err := renderer.Render(ctx, profileNoRole, inputs, vars)
if err == nil {
t.Error("expected error for empty message role")
}
})
}

View File

@@ -0,0 +1,11 @@
package validate
import (
"context"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
)
// Validator validates the generated artifact based on the output contract.
type Validator interface {
Validate(ctx context.Context, artifact *domain.Artifact, contract domain.OutputContract) (domain.ValidationResult, error)
}