336 lines
12 KiB
Go
336 lines
12 KiB
Go
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
|
|
}
|