520 lines
14 KiB
Go
520 lines
14 KiB
Go
package promptdef
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
"gitea.maximumdirect.net/eric/scriptorium/internal/filecatalog"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
ErrPromptDefinitionNotFound = errors.New("prompt definition not found")
|
|
ErrInvalidYAML = errors.New("invalid YAML format")
|
|
ErrInvalidPromptDefinition = errors.New("invalid prompt definition configuration")
|
|
)
|
|
|
|
type filesystemRepository struct {
|
|
dir string
|
|
}
|
|
|
|
type fsRepository struct {
|
|
fsys fs.FS
|
|
root string
|
|
}
|
|
|
|
type promptDefinitionFile struct {
|
|
ID string `yaml:"id"`
|
|
Version string `yaml:"version"`
|
|
DefaultProfile *string `yaml:"default_profile"`
|
|
Description string `yaml:"description"`
|
|
SessionID string `yaml:"session_id"`
|
|
Inputs []promptInputFile `yaml:"inputs"`
|
|
Messages []promptMessageFile `yaml:"messages"`
|
|
Output promptOutputContractFile `yaml:"output"`
|
|
}
|
|
|
|
type promptInputFile struct {
|
|
Name string `yaml:"name"`
|
|
Required bool `yaml:"required"`
|
|
ContentType string `yaml:"content_type"`
|
|
Description string `yaml:"description"`
|
|
}
|
|
|
|
type promptMessageFile struct {
|
|
Role string `yaml:"role"`
|
|
Content string `yaml:"content"`
|
|
ContentFile string `yaml:"content_file"`
|
|
CacheControl *cacheControlFile `yaml:"cache_control"`
|
|
}
|
|
|
|
type cacheControlFile struct {
|
|
Type string `yaml:"type"`
|
|
TTL string `yaml:"ttl"`
|
|
}
|
|
|
|
type promptOutputContractFile struct {
|
|
Format domain.OutputFormat `yaml:"format"`
|
|
ValidationMode domain.ValidationMode `yaml:"validation_mode"`
|
|
SchemaPath string `yaml:"schema_path"`
|
|
RepairAttempts int `yaml:"repair_attempts"`
|
|
}
|
|
|
|
func NewFilesystemRepository(dir string) Repository {
|
|
return &filesystemRepository{dir: dir}
|
|
}
|
|
|
|
func NewFSRepository(fsys fs.FS, root string) Repository {
|
|
return &fsRepository{fsys: fsys, root: root}
|
|
}
|
|
|
|
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
|
if strings.TrimSpace(id) == "" {
|
|
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
|
|
}
|
|
|
|
files, err := filecatalog.FindYAMLFiles(ctx, r.dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
|
}
|
|
|
|
var matches []promptDefinitionMatch
|
|
for _, fullPath := range files {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
relPath := filecatalog.RelativePath(r.dir, fullPath)
|
|
fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id
|
|
|
|
raw, err := loadPromptDefinitionFile(fullPath)
|
|
if err != nil {
|
|
if fileMatch || promptDefinitionFileHasID(fullPath, id) {
|
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
def, err := normalizePromptDefinition(raw, fullPath)
|
|
if err != nil {
|
|
if fileMatch || strings.TrimSpace(raw.ID) == id {
|
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if def.ID != id {
|
|
continue
|
|
}
|
|
if version != "" && def.Version != version {
|
|
continue
|
|
}
|
|
matches = append(matches, promptDefinitionMatch{
|
|
def: def,
|
|
path: relPath,
|
|
})
|
|
}
|
|
|
|
if len(matches) > 1 {
|
|
paths := make([]string, 0, len(matches))
|
|
for _, match := range matches {
|
|
paths = append(paths, match.path)
|
|
}
|
|
if version != "" {
|
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
|
|
}
|
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
|
|
}
|
|
|
|
if len(matches) == 1 {
|
|
return matches[0].def, nil
|
|
}
|
|
|
|
return nil, ErrPromptDefinitionNotFound
|
|
}
|
|
|
|
func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
|
return loadPromptDefinition(ctx, r.fsys, r.root, id, version)
|
|
}
|
|
|
|
type promptDefinitionMatch struct {
|
|
def *domain.PromptDefinition
|
|
path string
|
|
}
|
|
|
|
func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read prompt definition file: %w", err)
|
|
}
|
|
|
|
var raw promptDefinitionFile
|
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
|
decoder.KnownFields(true)
|
|
if err := decoder.Decode(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
return &raw, nil
|
|
}
|
|
|
|
func promptDefinitionFileHasID(path string, id string) bool {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var raw struct {
|
|
ID string `yaml:"id"`
|
|
}
|
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(raw.ID) == id
|
|
}
|
|
|
|
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id string, version string) (*domain.PromptDefinition, error) {
|
|
if strings.TrimSpace(id) == "" {
|
|
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
|
|
}
|
|
if fsys == nil {
|
|
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
|
|
}
|
|
|
|
files, err := findPromptDefinitionYAMLFiles(ctx, fsys, root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
|
}
|
|
|
|
var matches []promptDefinitionMatch
|
|
for _, fullPath := range files {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
relPath := displayPath(root, fullPath)
|
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
|
data, err := fs.ReadFile(fsys, fullPath)
|
|
if err != nil {
|
|
if fileMatch {
|
|
return nil, fmt.Errorf("%w: %s: failed to read prompt definition file: %v", ErrInvalidYAML, relPath, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
raw, err := decodePromptDefinition(data)
|
|
if err != nil {
|
|
if fileMatch || promptDefinitionDataHasID(data, id) {
|
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
def, err := normalizePromptDefinitionFromFS(raw, fsys, fullPath)
|
|
if err != nil {
|
|
if fileMatch || strings.TrimSpace(raw.ID) == id {
|
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if def.ID != id {
|
|
continue
|
|
}
|
|
if version != "" && def.Version != version {
|
|
continue
|
|
}
|
|
matches = append(matches, promptDefinitionMatch{
|
|
def: def,
|
|
path: relPath,
|
|
})
|
|
}
|
|
|
|
if len(matches) > 1 {
|
|
paths := make([]string, 0, len(matches))
|
|
for _, match := range matches {
|
|
paths = append(paths, match.path)
|
|
}
|
|
if version != "" {
|
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
|
|
}
|
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
|
|
}
|
|
|
|
if len(matches) == 1 {
|
|
return matches[0].def, nil
|
|
}
|
|
|
|
return nil, ErrPromptDefinitionNotFound
|
|
}
|
|
|
|
func findPromptDefinitionYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
|
cleanRoot := cleanFSRoot(root)
|
|
var files []string
|
|
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !isPromptDefinitionYAMLFile(d.Name()) {
|
|
return nil
|
|
}
|
|
files = append(files, name)
|
|
return nil
|
|
})
|
|
return files, err
|
|
}
|
|
|
|
func cleanFSRoot(root string) string {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" || root == "." {
|
|
return "."
|
|
}
|
|
return path.Clean(root)
|
|
}
|
|
|
|
func displayPath(root string, name string) string {
|
|
cleanRoot := cleanFSRoot(root)
|
|
cleanName := path.Clean(name)
|
|
if cleanRoot == "." {
|
|
return cleanName
|
|
}
|
|
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
|
|
if strings.HasPrefix(cleanName, prefix) {
|
|
return strings.TrimPrefix(cleanName, prefix)
|
|
}
|
|
return cleanName
|
|
}
|
|
|
|
func isPromptDefinitionYAMLFile(name string) bool {
|
|
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
|
}
|
|
|
|
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
|
|
var raw promptDefinitionFile
|
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
|
decoder.KnownFields(true)
|
|
if err := decoder.Decode(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
return &raw, nil
|
|
}
|
|
|
|
func promptDefinitionDataHasID(data []byte, id string) bool {
|
|
var raw struct {
|
|
ID string `yaml:"id"`
|
|
}
|
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(raw.ID) == id
|
|
}
|
|
|
|
func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) {
|
|
promptDir := filepath.Dir(sourcePath)
|
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
|
resolvedPath := strings.TrimSpace(contentFile)
|
|
if !filepath.IsAbs(resolvedPath) {
|
|
resolvedPath = filepath.Join(promptDir, resolvedPath)
|
|
}
|
|
resolvedPath = filepath.Clean(resolvedPath)
|
|
|
|
body, err := os.ReadFile(resolvedPath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return string(body), resolvedPath, nil
|
|
})
|
|
}
|
|
|
|
func normalizePromptDefinitionFromFS(raw *promptDefinitionFile, fsys fs.FS, sourcePath string) (*domain.PromptDefinition, error) {
|
|
promptDir := path.Dir(sourcePath)
|
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
|
resolvedPath := strings.TrimSpace(contentFile)
|
|
if !path.IsAbs(resolvedPath) {
|
|
resolvedPath = path.Join(promptDir, resolvedPath)
|
|
}
|
|
resolvedPath = strings.TrimPrefix(path.Clean(resolvedPath), "/")
|
|
|
|
body, err := fs.ReadFile(fsys, resolvedPath)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return string(body), resolvedPath, nil
|
|
})
|
|
}
|
|
|
|
func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContentFile func(string) (string, string, error)) (*domain.PromptDefinition, error) {
|
|
if raw == nil {
|
|
return nil, errors.New("prompt definition is nil")
|
|
}
|
|
|
|
id := strings.TrimSpace(raw.ID)
|
|
if id == "" {
|
|
return nil, errors.New("id is required")
|
|
}
|
|
|
|
version := strings.TrimSpace(raw.Version)
|
|
if version == "" {
|
|
return nil, errors.New("version is required")
|
|
}
|
|
|
|
if len(raw.Messages) == 0 {
|
|
return nil, errors.New("at least one message is required")
|
|
}
|
|
|
|
inputs := make([]domain.PromptInput, 0, len(raw.Inputs))
|
|
seenInputNames := make(map[string]struct{}, len(raw.Inputs))
|
|
for i, in := range raw.Inputs {
|
|
name := strings.TrimSpace(in.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("input %d has empty name", i)
|
|
}
|
|
if _, exists := seenInputNames[name]; exists {
|
|
return nil, fmt.Errorf("duplicate input name %q", name)
|
|
}
|
|
seenInputNames[name] = struct{}{}
|
|
|
|
inputs = append(inputs, domain.PromptInput{
|
|
Name: name,
|
|
Required: in.Required,
|
|
ContentType: strings.TrimSpace(in.ContentType),
|
|
Description: strings.TrimSpace(in.Description),
|
|
})
|
|
}
|
|
|
|
templates := make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
|
|
for i, msg := range raw.Messages {
|
|
role := strings.TrimSpace(msg.Role)
|
|
if role == "" {
|
|
return nil, fmt.Errorf("message %d role is required", i)
|
|
}
|
|
|
|
hasContent := strings.TrimSpace(msg.Content) != ""
|
|
hasContentFile := strings.TrimSpace(msg.ContentFile) != ""
|
|
if hasContent == hasContentFile {
|
|
return nil, fmt.Errorf("message %d (%s) must set exactly one of content or content_file", i, role)
|
|
}
|
|
|
|
cacheControl, err := normalizeCacheControl(msg.CacheControl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("message %d (%s) cache_control: %w", i, role, err)
|
|
}
|
|
|
|
templateContent := msg.Content
|
|
resolvedContentFile := ""
|
|
if hasContentFile {
|
|
body, resolvedPath, err := readContentFile(msg.ContentFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prompt %q message %d (%s): failed to read content_file %q: %w", id, i, role, msg.ContentFile, err)
|
|
}
|
|
templateContent = body
|
|
resolvedContentFile = resolvedPath
|
|
}
|
|
|
|
templates = append(templates, domain.PromptMessageTemplate{
|
|
Role: role,
|
|
Content: templateContent,
|
|
ContentFile: resolvedContentFile,
|
|
CacheControl: cacheControl,
|
|
})
|
|
}
|
|
|
|
if !isValidOutputFormat(raw.Output.Format) {
|
|
return nil, fmt.Errorf("invalid output format: %q", raw.Output.Format)
|
|
}
|
|
if !isValidValidationMode(raw.Output.ValidationMode) {
|
|
return nil, fmt.Errorf("invalid validation mode: %q", raw.Output.ValidationMode)
|
|
}
|
|
if raw.Output.ValidationMode == domain.ValidationJSONSchema && strings.TrimSpace(raw.Output.SchemaPath) == "" {
|
|
return nil, errors.New("output.schema_path is required when output.validation_mode is json_schema")
|
|
}
|
|
if raw.Output.RepairAttempts < 0 {
|
|
return nil, errors.New("output.repair_attempts must be greater than or equal to 0")
|
|
}
|
|
|
|
defaultProfile := ""
|
|
if raw.DefaultProfile != nil {
|
|
defaultProfile = strings.TrimSpace(*raw.DefaultProfile)
|
|
if defaultProfile == "" {
|
|
return nil, errors.New("default_profile must be a non-empty string when set")
|
|
}
|
|
}
|
|
|
|
return &domain.PromptDefinition{
|
|
ID: id,
|
|
Version: version,
|
|
DefaultProfile: defaultProfile,
|
|
Description: strings.TrimSpace(raw.Description),
|
|
SessionID: strings.TrimSpace(raw.SessionID),
|
|
Inputs: inputs,
|
|
Templates: templates,
|
|
OutputFormat: raw.Output.Format,
|
|
Validation: domain.OutputContract{
|
|
Format: raw.Output.Format,
|
|
ValidationMode: raw.Output.ValidationMode,
|
|
SchemaPath: strings.TrimSpace(raw.Output.SchemaPath),
|
|
RepairAttempts: raw.Output.RepairAttempts,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func normalizeCacheControl(raw *cacheControlFile) (*domain.CacheControl, error) {
|
|
if raw == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
cacheType := strings.TrimSpace(raw.Type)
|
|
if cacheType == "" {
|
|
return nil, errors.New("type is required")
|
|
}
|
|
if domain.CacheControlType(cacheType) != domain.CacheControlEphemeral {
|
|
return nil, fmt.Errorf("unsupported type %q", cacheType)
|
|
}
|
|
|
|
ttl := strings.TrimSpace(raw.TTL)
|
|
if ttl != "" && ttl != "1h" {
|
|
return nil, fmt.Errorf("unsupported ttl %q", ttl)
|
|
}
|
|
|
|
return &domain.CacheControl{
|
|
Type: domain.CacheControlType(cacheType),
|
|
TTL: ttl,
|
|
}, nil
|
|
}
|
|
|
|
func isValidOutputFormat(f domain.OutputFormat) bool {
|
|
switch f {
|
|
case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isValidValidationMode(m domain.ValidationMode) bool {
|
|
switch m {
|
|
case domain.ValidationNone, domain.ValidationBasic, domain.ValidationJSON, domain.ValidationJSONSchema:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|