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") ) type promptEntry struct { definition domain.PromptDefinition inspection domain.PromptInspection requirementInputs []prompt.InputMetadata hash string session *template.Template messages []*template.Template } func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) { for _, id := range sortedKeys(prompts) { definition := prompts[id] base := basePath.Key(id) 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) normalizedName := strings.TrimSpace(input.Name) if normalizedName != input.Name { configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata") inputsChanged = true } for _, field := range []struct { name string value **string }{ {"content_type", &input.ContentType}, {"description", &input.Description}, } { if *field.value != nil { 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") } // Normalize message metadata on execution-owned copies below. ConfigKit // records sequence mutations as whole-value replacements, which would // discard the effective source contexts of unchanged content_file fields. 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] = definition } } func (state *loaded) materializePrompts(prompts map[string]promptConfig, basePath configkit.Path) error { state.prompts = make(map[string]*promptEntry, len(prompts)) var metadata *configkit.Metadata lookupOrigin := func(path configkit.Path) (configkit.FieldInfo, bool) { if metadata == nil { captured := state.result.Metadata() metadata = &captured } return metadata.Lookup(path) } for _, id := range sortedKeys(prompts) { promptBase := basePath.Key(id) if strings.TrimSpace(id) == "" { return fmt.Errorf("%s: prompt ID must not be blank", promptBase) } entry, err := state.materializePrompt(id, prompts[id].Version, prompts[id], promptBase, lookupOrigin) if err != nil { return err } state.prompts[id] = entry } return nil } func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) { 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: stringPointerValue(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)) entry := &promptEntry{ messages: make([]*template.Template, 0, len(raw.Messages)), requirementInputs: make([]prompt.InputMetadata, 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 contentType := "" if input.ContentType != nil { contentType = *input.ContentType } description := "" if input.Description != nil { description = *input.Description } definition.Inputs = append(definition.Inputs, domain.PromptInput{ Name: input.Name, Required: input.Required, ContentType: contentType, Description: description, }) presence := state.promptMetadata[id][index] metadata := prompt.InputMetadata{Name: input.Name, Required: input.Required} if presence.contentType == fieldValue { metadata.ContentType = stringPointer(contentType) } if presence.description == fieldValue { metadata.Description = stringPointer(description) } entry.requirementInputs = append(entry.requirementInputs, metadata) } 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")) } role, err := domain.NormalizeMessageRole(*message.Role) if err != nil { return nil, fmt.Errorf("%s: %w", path.Key("role"), err) } 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, lookupOrigin) 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: 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: stringPointerValue(version), PromptHash: hash, DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...), OutputContract: contract, } return entry, nil } func stringPointer(value string) *string { copy := value return © } func stringPointerValue(value *string) *string { if value == nil { return nil } return stringPointer(*value) } func (state *loaded) readContent(fieldPath configkit.Path, name string, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (string, error) { if strings.TrimSpace(name) == "" { return "", fmt.Errorf("%s: content_file path must not be blank", fieldPath) } field, found := lookupOrigin(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) lookupPromptEntry(id string) (*promptEntry, error) { if state == nil || strings.TrimSpace(id) == "" { return nil, errPromptNotFound } entry := state.prompts[id] if entry == nil { return nil, errPromptNotFound } return entry, nil } func ownPromptDefinition(definition domain.PromptDefinition) *domain.PromptDefinition { definition.Version = stringPointerValue(definition.Version) 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 = © } } return &definition } func ownPromptInspection(inspection domain.PromptInspection) *domain.PromptInspection { inspection.PromptVersion = stringPointerValue(inspection.PromptVersion) inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...) return &inspection } func promptLookupError(err error) error { if errors.Is(err, errPromptNotFound) { return promptdef.ErrPromptDefinitionNotFound } return err } func (state *loaded) prompt(id string) (*domain.PromptDefinition, domain.PromptInspection, error) { entry, err := state.lookupPromptEntry(id) if err != nil { return nil, domain.PromptInspection{}, err } return ownPromptDefinition(entry.definition), *ownPromptInspection(entry.inspection), nil } // GetPromptSnapshot returns an independently owned captured definition and its // construction-time hash. func (state *loaded) GetPromptSnapshot(ctx context.Context, id string) (*domain.PromptDefinition, string, error) { if err := ctx.Err(); err != nil { return nil, "", err } entry, err := state.lookupPromptEntry(id) if err != nil { return nil, "", promptLookupError(err) } return ownPromptDefinition(entry.definition), entry.hash, nil } // GetPromptInspection returns an independently owned inspection of one // captured prompt without copying its execution templates. func (state *loaded) GetPromptInspection(ctx context.Context, id string) (*domain.PromptInspection, error) { if err := ctx.Err(); err != nil { return nil, err } entry, err := state.lookupPromptEntry(id) if err != nil { return nil, promptLookupError(err) } return ownPromptInspection(entry.inspection), nil } // GetPromptDefinition implements the runner's prompt lookup boundary. func (state *loaded) GetPromptDefinition(ctx context.Context, id string) (*domain.PromptDefinition, error) { definition, _, err := state.GetPromptSnapshot(ctx, id) return definition, err } // GetCompiledTemplates returns construction-time parses for one prompt ID. func (state *loaded) GetCompiledTemplates(id string) (prompt.CompiledTemplates, bool) { if state == nil { return prompt.CompiledTemplates{}, false } entry := state.prompts[id] if entry == nil { return prompt.CompiledTemplates{}, false } return prompt.CompiledTemplates{Session: entry.session, Messages: entry.messages}, true } // GetRequirementSnapshot returns captured templates and input metadata for // one prompt ID. Returned metadata and slices are independently // owned; the compiled templates are shared immutable construction-time parses. func (state *loaded) GetRequirementSnapshot(id string) (prompt.RequirementSnapshot, bool) { if state == nil { return prompt.RequirementSnapshot{}, false } entry := state.prompts[id] if entry == nil { return prompt.RequirementSnapshot{}, false } snapshot := prompt.RequirementSnapshot{ Templates: prompt.CompiledTemplates{ Session: entry.session, Messages: append([]*template.Template(nil), entry.messages...), }, Inputs: make([]prompt.InputMetadata, len(entry.requirementInputs)), } for index, input := range entry.requirementInputs { snapshot.Inputs[index] = input if input.ContentType != nil { snapshot.Inputs[index].ContentType = stringPointer(*input.ContentType) } if input.Description != nil { snapshot.Inputs[index].Description = stringPointer(*input.Description) } } return snapshot, true }