package promptdef import ( "bytes" "context" "errors" "fmt" "os" "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 promptDefinitionFile struct { ID string `yaml:"id"` Version string `yaml:"version"` DefaultProfile *string `yaml:"default_profile"` Description string `yaml:"description"` 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"` } 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 (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 } 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 normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*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)) promptDir := filepath.Dir(sourcePath) 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) } templateContent := msg.Content resolvedContentFile := "" if hasContentFile { resolvedPath := strings.TrimSpace(msg.ContentFile) if !filepath.IsAbs(resolvedPath) { resolvedPath = filepath.Join(promptDir, resolvedPath) } resolvedPath = filepath.Clean(resolvedPath) body, err := os.ReadFile(resolvedPath) 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 = string(body) resolvedContentFile = resolvedPath } templates = append(templates, domain.PromptMessageTemplate{ Role: role, Content: templateContent, ContentFile: resolvedContentFile, }) } 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), 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 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 } }