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 }