package promptdef import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/scriptorium/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 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 := os.ReadDir(r.dir) if err != nil { return nil, fmt.Errorf("failed to read prompt definition directory: %w", err) } for _, file := range files { select { case <-ctx.Done(): return nil, ctx.Err() default: } if file.IsDir() || !isYAMLFile(file.Name()) { continue } fullPath := filepath.Join(r.dir, file.Name()) fileMatch := promptIDFromFileName(file.Name()) == id raw, err := loadPromptDefinitionFile(fullPath) if err != nil { if fileMatch { return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, file.Name(), 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, file.Name(), err) } continue } if def.ID != id { continue } if version != "" && def.Version != version { continue } return def, nil } return nil, ErrPromptDefinitionNotFound } 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 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 isYAMLFile(name string) bool { return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") } func promptIDFromFileName(name string) string { name = strings.TrimSuffix(name, ".yaml") name = strings.TrimSuffix(name, ".yml") return name } 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 } }