Capture keyed prompts and source-backed content at load time

This commit is contained in:
2026-09-30 02:19:52 +00:00
parent 2b847ac2ae
commit b158a8ec2d
12 changed files with 722 additions and 17 deletions

View File

@@ -89,9 +89,11 @@ const (
)
type loaded struct {
result *configkit.Result[document]
backends map[string]map[string]fieldState
profiles map[string]map[string]fieldState
result *configkit.Result[document]
backends map[string]map[string]fieldState
profiles map[string]map[string]fieldState
prompts map[string]map[string]*promptEntry
ambiguous map[string]bool
}
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
@@ -114,6 +116,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
captureFields(config.Profiles, "profiles", profileFields, resolution, state.profiles)
resolveBackends(config.Backends, resolution)
resolveProfiles(config.Profiles, resolution)
resolvePrompts(config.Prompts, resolution)
return nil
},
Validate: validateEffective,
@@ -123,6 +126,9 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
return nil, fmt.Errorf("load configuration: %w", err)
}
state.result = result
if err := state.materializePrompts(); err != nil {
return nil, fmt.Errorf("load configuration: %w", err)
}
return state, nil
}
@@ -153,10 +159,12 @@ func sortedKeys[T any](entries map[string]T) []string {
return keys
}
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) {
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) bool {
if value != nil && *value != nil && **value != normalized {
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
return true
}
return false
}
func resolveBackends(backends map[string]backendConfig, resolution *configkit.Resolution) {

View File

@@ -0,0 +1,312 @@
package configuration
import (
"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 = &copy
}
}
inspection := entry.inspection
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
return &definition, inspection, nil
}

View File

@@ -0,0 +1,189 @@
package configuration
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit"
)
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
files := fstest.MapFS{
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\nprompts:\n child:\n extends: base\n")},
"catalog/base.yaml": {Data: []byte("prompts:\n base:\n v1:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
}
namespace := configkit.NewFilesystem("embedded", files)
state, err := load(configkit.Input{
Source: namespace.File("app/main.yaml"), Imports: true,
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
}, nil)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("%v: %+v", err, loadError.Diagnostics())
}
t.Fatal(err)
}
definition, inspection, err := state.prompt("child", "")
if err != nil {
t.Fatal(err)
}
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
}
files["catalog/body.md"].Data = []byte("Changed")
again, againInspection, err := state.prompt("child", "v1")
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
t.Fatalf("captured definition changed after source mutation: %+v, %+v, %v", again, againInspection, err)
}
definition.Templates[0].Content = "caller mutation"
third, _, err := state.prompt("child", "v1")
if err != nil || third.Templates[0].Content != "Captured {{.name}}" {
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
}
}
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
filesystem := &countingFS{FS: fstest.MapFS{
"assets/body.md": {Data: []byte("From mapping")},
}}
namespace := configkit.NewFilesystem("mapping-context", filesystem)
source := configkit.Mapping("application", promptWithFile("body.md"))
state, err := load(configkit.Input{
Source: source,
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
}, nil)
if err != nil {
t.Fatal(err)
}
definition, _, err := state.prompt("example", "v1")
if err != nil || definition.Templates[0].Content != "From mapping" || filesystem.contentOpened != 1 || filesystem.contentClosed != 1 {
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
}
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
if err == nil || !strings.Contains(err.Error(), "/prompts/example/v1/messages/0/content_file") {
t.Fatalf("expected semantic path for contextless resource, got %v", err)
}
}
func TestLoadCapturesNativeContentFile(t *testing.T) {
root := t.TempDir()
definitionPath := filepath.Join(root, "definition.yaml")
contentPath := filepath.Join(root, "body.md")
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n v1:\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
t.Fatal(err)
}
state, err := load(configkit.Input{Source: configkit.File(definitionPath), Filesystem: &configkit.FilesystemContext{Root: root}}, nil)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(contentPath, []byte("Modified"), 0o600); err != nil {
t.Fatal(err)
}
definition, _, err := state.prompt("example", "v1")
if err != nil || definition.Templates[0].Content != "Native body" {
t.Fatalf("native content was not captured: %+v, %v", definition, err)
}
}
func TestLoadRejectsMalformedStaticSyntaxAndAmbiguousSelection(t *testing.T) {
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
"v1": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
}}}), nil)
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
t.Fatalf("expected construction-time syntax error, got %v", err)
}
state, err := load(mapping("versions", map[string]any{"prompts": map[string]any{"example": map[string]any{
"v1": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "one"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
"v2": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "two"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
}}}), nil)
if err != nil {
t.Fatal(err)
}
if _, _, err := state.prompt("example", ""); !errors.Is(err, errPromptAmbiguous) {
t.Fatalf("expected ambiguous version selection, got %v", err)
}
}
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{"v1": map[string]any{
"inputs": []any{map[string]any{"name": " note ", "required": true}},
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}}), nil)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("%v: %+v", err, loadError.Diagnostics())
}
t.Fatal(err)
}
definition, _, err := state.prompt(" exact.id ", "v1")
if err != nil || definition.ID != " exact.id " || definition.Templates[0].Content != "" || definition.Templates[0].Role != "user" ||
definition.Inputs[0].Name != "note" || definition.Templates[0].CacheControl.TTL != "1h" {
t.Fatalf("explicit empty content or literal ID was changed: %+v, %v", definition, err)
}
if _, _, err := state.prompt("exact.id", "v1"); !errors.Is(err, errPromptNotFound) {
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
}
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{"v1": map[string]any{
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}}), nil)
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
}
_, err = load(mapping("null-prompt", map[string]any{"prompts": map[string]any{"example": nil}}), nil)
if err == nil {
t.Fatal("null prompt collection was accepted")
}
}
func promptWithFile(name string) map[string]any {
return map[string]any{"prompts": map[string]any{"example": map[string]any{"v1": map[string]any{
"messages": []any{map[string]any{"role": "user", "content_file": name}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}}
}
type countingFS struct {
fs.FS
contentOpened int
contentClosed int
}
func (filesystem *countingFS) Open(name string) (fs.File, error) {
file, err := filesystem.FS.Open(name)
if err != nil {
return nil, err
}
if name == "assets/body.md" {
filesystem.contentOpened++
return &countingFile{File: file, owner: filesystem}, nil
}
return file, nil
}
type countingFile struct {
fs.File
owner *countingFS
}
func (file *countingFile) Close() error {
file.owner.contentClosed++
return file.File.Close()
}

View File

@@ -76,7 +76,7 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini
if err := ctx.Err(); err != nil {
return nil, err
}
tmpl, parseErr := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Option("missingkey=error").Parse(tmplMsg.Content)
tmpl, parseErr := parseTemplate(fmt.Sprintf("msg_%d", i), tmplMsg.Content, funcs)
if err := ctx.Err(); err != nil {
return nil, err
}
@@ -167,7 +167,7 @@ func renderSessionID(ctx context.Context, raw string, funcs template.FuncMap, va
if err := ctx.Err(); err != nil {
return "", err
}
tmpl, parseErr := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw)
tmpl, parseErr := parseTemplate("session_id", raw, funcs)
if err := ctx.Err(); err != nil {
return "", err
}

View File

@@ -0,0 +1,15 @@
package prompt
import "text/template"
// CompileTemplate checks static syntax with the same grammar and function
// names as rendering, without evaluating request inputs or variables.
func CompileTemplate(name, raw string) (*template.Template, error) {
return parseTemplate(name, raw, template.FuncMap{
"input": func(string) (string, error) { return "", nil },
})
}
func parseTemplate(name, raw string, funcs template.FuncMap) (*template.Template, error) {
return template.New(name).Funcs(funcs).Option("missingkey=error").Parse(raw)
}

View File

@@ -0,0 +1,19 @@
package promptdef
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
// HashDefinition hashes an execution-ready prompt, including captured content.
func HashDefinition(definition *domain.PromptDefinition) (string, error) {
bytes, err := json.Marshal(definition)
if err != nil {
return "", err
}
sum := sha256.Sum256(bytes)
return hex.EncodeToString(sum[:]), nil
}

View File

@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
@@ -809,10 +808,5 @@ func buildOutputArtifact(content string, format domain.OutputFormat) domain.Arti
}
func hashPromptDefinition(def *domain.PromptDefinition) (string, error) {
b, err := json.Marshal(def)
if err != nil {
return "", err
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:]), nil
return promptdef.HashDefinition(def)
}