346 lines
9.9 KiB
Go
346 lines
9.9 KiB
Go
package promptdef
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
"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 sourceRepository struct {
|
|
source promptDefinitionSource
|
|
}
|
|
|
|
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 &sourceRepository{
|
|
source: osPromptSource{
|
|
root: dir,
|
|
contentRoot: osContentSourceRoot{root: dir},
|
|
},
|
|
}
|
|
}
|
|
|
|
func NewFSRepository(fsys fs.FS, root string) Repository {
|
|
return &sourceRepository{
|
|
source: fsPromptSource{
|
|
fsys: fsys,
|
|
root: root,
|
|
contentRoot: fsContentSourceRoot{fsys: fsys, root: root},
|
|
},
|
|
}
|
|
}
|
|
|
|
// NewFileRepository constructs a repository for one operating-system prompt file.
|
|
func NewFileRepository(fsys fs.FS, file string, sourceDir string) Repository {
|
|
return &sourceRepository{
|
|
source: fsPromptSource{
|
|
fsys: fsys,
|
|
root: file,
|
|
contentRoot: osContentSourceRoot{
|
|
root: sourceDir,
|
|
sourcePathsRelative: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *sourceRepository) 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)
|
|
}
|
|
if r == nil || r.source == nil {
|
|
return nil, errors.New("failed to read prompt definition directory: source is nil")
|
|
}
|
|
|
|
files, err := r.source.findYAMLFiles(ctx)
|
|
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 := r.source.displayPath(fullPath)
|
|
data, err := r.source.readDefinition(fullPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read prompt definition file %s: %w", relPath, err)
|
|
}
|
|
raw, err := decodePromptDefinition(data)
|
|
if err != nil {
|
|
if promptDefinitionDataMatches(data, id, version) {
|
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
|
}
|
|
continue
|
|
}
|
|
if !promptDefinitionMatches(raw, id, version) {
|
|
continue
|
|
}
|
|
matches = append(matches, promptDefinitionMatch{
|
|
raw: raw,
|
|
sourcePath: fullPath,
|
|
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 {
|
|
match := matches[0]
|
|
def, err := normalizePromptDefinition(match.raw, r.source, match.sourcePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, match.path, err)
|
|
}
|
|
return def, nil
|
|
}
|
|
|
|
return nil, ErrPromptDefinitionNotFound
|
|
}
|
|
|
|
type promptDefinitionMatch struct {
|
|
raw *promptDefinitionFile
|
|
sourcePath string
|
|
path string
|
|
}
|
|
|
|
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
|
|
}
|
|
var additional yaml.Node
|
|
if err := decoder.Decode(&additional); err != io.EOF {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, errors.New("prompt definition file must contain exactly one YAML document")
|
|
}
|
|
return &raw, nil
|
|
}
|
|
|
|
func promptDefinitionDataMatches(data []byte, id string, version string) bool {
|
|
var raw struct {
|
|
ID string `yaml:"id"`
|
|
Version string `yaml:"version"`
|
|
}
|
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
|
return false
|
|
}
|
|
return promptSelectorMatches(raw.ID, raw.Version, id, version)
|
|
}
|
|
|
|
func promptDefinitionMatches(raw *promptDefinitionFile, id string, version string) bool {
|
|
if raw == nil {
|
|
return false
|
|
}
|
|
return promptSelectorMatches(raw.ID, raw.Version, id, version)
|
|
}
|
|
|
|
func promptSelectorMatches(rawID string, rawVersion string, id string, version string) bool {
|
|
if strings.TrimSpace(rawID) != id {
|
|
return false
|
|
}
|
|
return version == "" || strings.TrimSpace(rawVersion) == version
|
|
}
|
|
|
|
func normalizePromptDefinition(raw *promptDefinitionFile, sourceRoot contentSourceRoot, sourcePath string) (*domain.PromptDefinition, error) {
|
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
|
return sourceRoot.readContentFile(sourcePath, contentFile)
|
|
})
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
outputContract := domain.OutputContract{
|
|
Format: raw.Output.Format,
|
|
ValidationMode: raw.Output.ValidationMode,
|
|
SchemaPath: strings.TrimSpace(raw.Output.SchemaPath),
|
|
RepairAttempts: raw.Output.RepairAttempts,
|
|
}
|
|
if err := domain.ValidateOutputContract(outputContract); err != nil {
|
|
return nil, fmt.Errorf("output: %w", err)
|
|
}
|
|
|
|
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: outputContract,
|
|
}, 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
|
|
}
|