410 lines
14 KiB
Go
410 lines
14 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")
|
|
)
|
|
|
|
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
|
|
}
|