package configuration import ( "context" "errors" "fmt" "io" "strings" "text/template" "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/filecatalog" "gitea.maximumdirect.net/eric/promptkit/internal/prompt" "gitea.maximumdirect.net/eric/promptkit/internal/promptdef" ) var ( errPromptNotFound = errors.New("prompt definition not found") errPromptAmbiguous = errors.New("prompt version is ambiguous") ) type promptEntry struct { definition domain.PromptDefinition inspection domain.PromptInspection hash string session *template.Template messages []*template.Template } func resolvePrompts(prompts map[string]map[string]promptConfig, resolution *configkit.Resolution) { for _, id := range sortedKeys(prompts) { for _, version := range sortedKeys(prompts[id]) { definition := prompts[id][version] base := configkit.Keys("prompts", id, version) for _, field := range []struct { name string value **string }{ {"description", &definition.Description}, {"session_id", &definition.SessionID}, } { if *field.value != nil { normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value)) } } inputsChanged := false for index := range definition.Inputs { input := &definition.Inputs[index] inputPath := base.Key("inputs").Index(index) for _, field := range []struct { name string value *string }{ {"name", &input.Name}, {"content_type", &input.ContentType}, {"description", &input.Description}, } { normalized := strings.TrimSpace(*field.value) if normalized != *field.value { configkit.Normalize(resolution, inputPath.Key(field.name), field.value, normalized, "normalize input metadata") inputsChanged = true } } } if inputsChanged { resolution.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata") } messagesChanged := false for index := range definition.Messages { message := &definition.Messages[index] messagePath := base.Key("messages").Index(index) if message.Role != nil { role, err := domain.NormalizeMessageRole(*message.Role) if err != nil { resolution.Error(messagePath.Key("role"), err.Error()) } else { messagesChanged = normalizeString(&message.Role, messagePath.Key("role"), resolution, role) || messagesChanged } } if control := message.CacheControl; control != nil && control.Type != nil { value := &domain.CacheControl{Type: domain.CacheControlType(*control.Type)} if control.TTL != nil { value.TTL = *control.TTL } normalized, err := domain.NormalizeCacheControl(value) if err != nil { resolution.Error(messagePath.Key("cache_control"), err.Error()) } else { messagesChanged = normalizeString(&control.Type, messagePath.Key("cache_control").Key("type"), resolution, string(normalized.Type)) || messagesChanged if control.TTL != nil { messagesChanged = normalizeString(&control.TTL, messagePath.Key("cache_control").Key("ttl"), resolution, normalized.TTL) || messagesChanged } } } } if messagesChanged { resolution.Record(base.Key("messages"), configkit.ChangeNormalization, "normalize message metadata") } if definition.Output != nil && definition.Output.SchemaPath != nil { normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath)) } prompts[id][version] = definition } } } func (state *loaded) materializePrompts() error { state.prompts = make(map[string]map[string]*promptEntry, len(state.result.Config.Prompts)) state.ambiguous = make(map[string]bool) for _, id := range sortedKeys(state.result.Config.Prompts) { if strings.TrimSpace(id) == "" { return fmt.Errorf("%s: prompt ID must not be blank", configkit.Keys("prompts", id)) } versions := state.result.Config.Prompts[id] if versions == nil { return fmt.Errorf("%s: prompt collection must be a mapping", configkit.Keys("prompts", id)) } state.prompts[id] = make(map[string]*promptEntry, len(versions)) state.ambiguous[id] = len(versions) > 1 for _, version := range sortedKeys(versions) { if strings.TrimSpace(version) == "" { return fmt.Errorf("%s: prompt version must not be blank", configkit.Keys("prompts", id, version)) } entry, err := state.materializePrompt(id, version, versions[version]) if err != nil { return err } state.prompts[id][version] = entry } } return nil } func (state *loaded) materializePrompt(id, version string, raw promptConfig) (*promptEntry, error) { base := configkit.Keys("prompts", id, version) if len(raw.Messages) == 0 { return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages")) } definition := domain.PromptDefinition{ID: id, Version: version} if raw.DefaultProfile != nil { if strings.TrimSpace(*raw.DefaultProfile) == "" { return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile")) } definition.DefaultProfile = *raw.DefaultProfile } if raw.Description != nil { definition.Description = *raw.Description } if raw.SessionID != nil { definition.SessionID = *raw.SessionID } seenInputs := make(map[string]bool, len(raw.Inputs)) definition.Inputs = make([]domain.PromptInput, 0, len(raw.Inputs)) for index, input := range raw.Inputs { path := base.Key("inputs").Index(index) if input.Name == "" { return nil, fmt.Errorf("%s: input name is required", path.Key("name")) } if seenInputs[input.Name] { return nil, fmt.Errorf("%s: input name is duplicated", path.Key("name")) } seenInputs[input.Name] = true definition.Inputs = append(definition.Inputs, domain.PromptInput{ Name: input.Name, Required: input.Required, ContentType: input.ContentType, Description: input.Description, }) } entry := &promptEntry{messages: make([]*template.Template, 0, len(raw.Messages))} definition.Templates = make([]domain.PromptMessageTemplate, 0, len(raw.Messages)) for index, message := range raw.Messages { path := base.Key("messages").Index(index) if message.Role == nil { return nil, fmt.Errorf("%s: role is required", path.Key("role")) } if (message.Content != nil) == (message.ContentFile != nil) { return nil, fmt.Errorf("%s: exactly one of content or content_file is required", path) } content := "" if message.Content != nil { content = *message.Content } else { var err error content, err = state.readContent(path.Key("content_file"), *message.ContentFile) if err != nil { return nil, err } } var control *domain.CacheControl if message.CacheControl != nil { if message.CacheControl.Type == nil { return nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type")) } control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)} if message.CacheControl.TTL != nil { control.TTL = *message.CacheControl.TTL } var err error control, err = domain.NormalizeCacheControl(control) if err != nil { return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err) } } compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content) if err != nil { return nil, fmt.Errorf("%s: invalid template syntax: %w", path, err) } entry.messages = append(entry.messages, compiled) definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{ Role: *message.Role, Content: content, CacheControl: control, }) } if raw.Output == nil { return nil, fmt.Errorf("%s: output is required", base.Key("output")) } if raw.Output.Format == nil || raw.Output.ValidationMode == nil { return nil, fmt.Errorf("%s: format and validation_mode are required", base.Key("output")) } contract := domain.OutputContract{ Format: domain.OutputFormat(*raw.Output.Format), ValidationMode: domain.ValidationMode(*raw.Output.ValidationMode), RepairAttempts: raw.Output.RepairAttempts, } if raw.Output.SchemaPath != nil { contract.SchemaPath = *raw.Output.SchemaPath } if err := domain.ValidateOutputContract(contract); err != nil { return nil, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err) } definition.OutputFormat = contract.Format definition.Validation = contract if raw.SessionID != nil { var err error entry.session, err = prompt.CompileTemplate("session_id", definition.SessionID) if err != nil { return nil, fmt.Errorf("%s: invalid template syntax: %w", base.Key("session_id"), err) } } hash, err := promptdef.HashDefinition(&definition) if err != nil { return nil, fmt.Errorf("%s: hash prompt definition: %w", base, err) } entry.definition = definition entry.hash = hash entry.inspection = domain.PromptInspection{ PromptID: id, PromptVersion: version, PromptHash: hash, DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...), OutputContract: contract, } return entry, nil } func (state *loaded) readContent(fieldPath configkit.Path, name string) (string, error) { if strings.TrimSpace(name) == "" { return "", fmt.Errorf("%s: content_file path must not be blank", fieldPath) } field, found := state.result.Metadata().Lookup(fieldPath) if !found || len(field.Origin.Locations) != 1 { return "", fmt.Errorf("%s: content_file needs one contributing source with a filesystem context", fieldPath) } sourceID := field.Origin.Locations[0].SourceID context, found := state.result.SourceContext(sourceID) if !found || context.FS == nil { return "", fmt.Errorf("%s: content_file source %s has no filesystem context", fieldPath, sourceID) } resolved, _, err := filecatalog.ResolveFSPath(".", context.BaseDir, name) if err != nil { return "", fmt.Errorf("%s: content_file path is outside its source root or invalid: %w", fieldPath, err) } file, err := context.FS.Open(resolved) if err != nil { return "", fmt.Errorf("%s: open content_file from source %s: %w", fieldPath, sourceID, err) } content, readErr := io.ReadAll(file) closeErr := file.Close() if readErr != nil { return "", fmt.Errorf("%s: read content_file from source %s: %w", fieldPath, sourceID, readErr) } if closeErr != nil { return "", fmt.Errorf("%s: close content_file from source %s: %w", fieldPath, sourceID, closeErr) } return string(content), nil } func (state *loaded) prompt(id, version string) (*domain.PromptDefinition, domain.PromptInspection, error) { if state == nil || strings.TrimSpace(id) == "" || (version != "" && strings.TrimSpace(version) == "") { return nil, domain.PromptInspection{}, errPromptNotFound } versions := state.prompts[id] if version == "" { if state.ambiguous[id] { return nil, domain.PromptInspection{}, errPromptAmbiguous } for selected := range versions { version = selected } } entry := versions[version] if entry == nil { return nil, domain.PromptInspection{}, errPromptNotFound } definition := entry.definition definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...) definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...) for index := range definition.Templates { if control := definition.Templates[index].CacheControl; control != nil { copy := *control definition.Templates[index].CacheControl = © } } inspection := entry.inspection inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...) return &definition, inspection, nil } // GetPromptSnapshot returns an independently owned captured definition and its // construction-time hash. Lookup retains literal ID and version keys. func (state *loaded) GetPromptSnapshot(ctx context.Context, id, version string) (*domain.PromptDefinition, string, error) { if err := ctx.Err(); err != nil { return nil, "", err } definition, inspection, err := state.prompt(id, version) if errors.Is(err, errPromptNotFound) { return nil, "", promptdef.ErrPromptDefinitionNotFound } if err != nil { return nil, "", err } return definition, inspection.PromptHash, nil } // GetPromptDefinition implements the runner's prompt lookup boundary. func (state *loaded) GetPromptDefinition(ctx context.Context, id, version string) (*domain.PromptDefinition, error) { definition, _, err := state.GetPromptSnapshot(ctx, id, version) return definition, err }