From 7a98feb39951e74dbc9413799925e1811b940cbb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 3 Nov 2026 03:28:31 +0000 Subject: [PATCH] Record prompt field normalization --- docs/roadmap/implementation.md | 4 ++ internal/configuration/interpretation_test.go | 41 ++++++++++- internal/configuration/load.go | 3 +- internal/configuration/prompts.go | 47 +------------ internal/configuration/prompts_test.go | 6 +- internal/configuration/resolve_prompts.go | 68 +++++++++++++++++++ 6 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 internal/configuration/resolve_prompts.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3879961..a7d55da 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -157,6 +157,10 @@ Validation: prompt/configuration tests plus existing domain-message tests. Inval roles, cache-control values, and missing required message fields still fail; explicit empty inline content retains its existing behavior. +Completion: prompt metadata and configured message fields are normalized and +recorded during resolution. Focused configuration and domain tests pass, +including equivalent prompt hashes and retained input presence. + ## Stage 4: Use The Single Resolved Configuration Scope: loader/target/materialization wiring and affected tests. diff --git a/internal/configuration/interpretation_test.go b/internal/configuration/interpretation_test.go index 383c368..0bed8ec 100644 --- a/internal/configuration/interpretation_test.go +++ b/internal/configuration/interpretation_test.go @@ -58,7 +58,7 @@ func TestTargetResolutionNormalizesNestedValues(t *testing.T) { } } -func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t *testing.T) { +func TestPromptResolutionNormalizesOutputAndRejectsDuplicateNames(t *testing.T) { definition := map[string]any{ "id": "example", "inputs": []any{map[string]any{"name": " document "}}, "messages": []any{map[string]any{"role": "user", "content": "hello"}}, @@ -73,8 +73,8 @@ func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t * t.Fatalf("interpreted prompt = %+v, %+v, %v", selected, inspection, err) } composed := state.result.Config.Prompts[0] - if *composed.Output.SchemaPath != " schema.json " || composed.Inputs[0].Name != " document " { - t.Fatalf("composed prompt changed: %+v", composed) + if *composed.Output.SchemaPath != "schema.json" || composed.Inputs[0].Name != "document" { + t.Fatalf("prompt was not normalized: %+v", composed) } definition["inputs"] = []any{map[string]any{"name": " document "}, map[string]any{"name": "document"}} _, err = load(mapping("duplicate inputs", map[string]any{"prompts": []any{definition}}), nil) @@ -82,3 +82,38 @@ func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t * t.Fatalf("duplicate normalized names were not rejected: %v", err) } } + +func TestPromptResolutionRecordsMessageFields(t *testing.T) { + loadPrompt := func(role, cacheType, ttl string) *loaded { + t.Helper() + state, err := load(mapping("prompt", map[string]any{"prompts": []any{map[string]any{ + "id": "example", "inputs": []any{map[string]any{"name": " input ", "content_type": nil, "description": " text "}}, + "messages": []any{map[string]any{"role": role, "content": "", "cache_control": map[string]any{"type": cacheType, "ttl": ttl}}}, + "output": map[string]any{"format": "text", "validation_mode": "basic"}, + }}}), nil) + if err != nil { + t.Fatal(err) + } + return state + } + padded := loadPrompt(" USER ", " ephemeral ", " 1h ") + canonical := loadPrompt("user", "ephemeral", "1h") + definition, inspection, err := padded.prompt("example") + if err != nil { + t.Fatal(err) + } + other, otherInspection, err := canonical.prompt("example") + if err != nil || inspection.PromptHash != otherInspection.PromptHash || definition.Templates[0].Role != other.Templates[0].Role || definition.Templates[0].Content != "" { + t.Fatalf("equivalent prompt mismatch: %+v %+v %v", definition, other, err) + } + base := configkit.Keys("prompts").Index(0) + for _, path := range []configkit.Path{base.Key("inputs").Index(0).Key("name"), base.Key("inputs").Index(0).Key("description"), base.Key("messages").Index(0).Key("role"), base.Key("messages").Index(0).Key("cache_control").Key("type"), base.Key("messages").Index(0).Key("cache_control").Key("ttl")} { + field, found := padded.result.Metadata().Lookup(path) + if !found || field.Origin.Operation != configkit.ChangeNormalization { + t.Errorf("normalization at %v: %+v", path, field) + } + } + if padded.promptMetadata["example"][0].contentType != fieldNull { + t.Fatal("null input metadata was not retained") + } +} diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 4214bfc..5a5eb67 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -245,6 +245,7 @@ func configurationDefinition(state *loaded) configkit.Definition[document] { for _, resources := range resourceSets(config) { resolveBackends(resources.backends, resources.bases.backends, resolution) resolveProfiles(resources.profiles, resources.bases.profiles, resolution) + resolvePrompts(resources.prompts, resources.bases.prompts, resolution) } return nil }, @@ -409,12 +410,10 @@ func sortedKeys[T any](entries map[string]T) []string { func interpretConfiguration(config *document, validation *configkit.Validation) document { interpreted := *config - interpreted.Prompts = interpretPrompts(config.Prompts) if config.Collections != nil { interpreted.Collections = make(map[string]collectionConfig, len(config.Collections)) for namespace, original := range config.Collections { collection := original - collection.Prompts = interpretPrompts(original.Prompts) interpreted.Collections[namespace] = collection } } diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go index ea75c7c..9b354e9 100644 --- a/internal/configuration/prompts.go +++ b/internal/configuration/prompts.go @@ -28,44 +28,6 @@ type promptEntry struct { messages []*template.Template } -func interpretPrompts(source []promptConfig) []promptConfig { - if source == nil { - return nil - } - prompts := append([]promptConfig{}, source...) - for index := range prompts { - definition := &prompts[index] - for _, value := range []**string{&definition.Description, &definition.SessionID} { - if *value != nil { - normalized := strings.TrimSpace(**value) - *value = &normalized - } - } - if definition.Inputs != nil { - definition.Inputs = append([]promptInputConfig{}, definition.Inputs...) - for index := range definition.Inputs { - input := &definition.Inputs[index] - input.Name = strings.TrimSpace(input.Name) - for _, value := range []**string{&input.ContentType, &input.Description} { - if *value != nil { - normalized := strings.TrimSpace(**value) - *value = &normalized - } - } - } - } - if definition.Output != nil { - output := *definition.Output - definition.Output = &output - if output.SchemaPath != nil { - normalized := strings.TrimSpace(*output.SchemaPath) - output.SchemaPath = &normalized - } - } - } - return prompts -} - func (state *loaded) materializePrompts() error { state.prompts = make(map[string]*promptEntry) var metadata *configkit.Metadata @@ -152,8 +114,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk if message.Role == nil { return nil, fmt.Errorf("%s: role is required", path.Key("role")) } - role, err := domain.NormalizeMessageRole(*message.Role) - if err != nil { + if _, err := domain.NormalizeMessageRole(*message.Role); err != nil { return nil, fmt.Errorf("%s: %w", path.Key("role"), err) } if (message.Content != nil) == (message.ContentFile != nil) { @@ -178,9 +139,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk if message.CacheControl.TTL != nil { control.TTL = *message.CacheControl.TTL } - var err error - control, err = domain.NormalizeCacheControl(control) - if err != nil { + if _, err := domain.NormalizeCacheControl(control); err != nil { return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err) } } @@ -190,7 +149,7 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk } entry.messages = append(entry.messages, compiled) definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{ - Role: role, Content: content, CacheControl: control, + Role: *message.Role, Content: content, CacheControl: control, }) } if raw.Output == nil { diff --git a/internal/configuration/prompts_test.go b/internal/configuration/prompts_test.go index fb2cfb8..cb13733 100644 --- a/internal/configuration/prompts_test.go +++ b/internal/configuration/prompts_test.go @@ -60,9 +60,9 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { t.Fatal("inherited prompt missing from effective list") } composed := state.result.Config.Collections["docs"].Prompts[childIndex] - if *composed.Description != " description " || *composed.SessionID != " session " || composed.Inputs[0].Name != " document " || - *composed.Inputs[0].ContentType != " text/plain " || *composed.Inputs[0].Description != " document source " { - t.Fatalf("composed metadata was changed: %+v", composed) + if *composed.Description != "description" || *composed.SessionID != "session" || composed.Inputs[0].Name != "document" || + *composed.Inputs[0].ContentType != "text/plain" || *composed.Inputs[0].Description != "document source" { + t.Fatalf("resolved metadata was not normalized: %+v", composed) } field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts").Index(childIndex).Key("messages").Index(0).Key("content_file")) if !ok || len(field.Origin.Locations) != 1 { diff --git a/internal/configuration/resolve_prompts.go b/internal/configuration/resolve_prompts.go new file mode 100644 index 0000000..3928276 --- /dev/null +++ b/internal/configuration/resolve_prompts.go @@ -0,0 +1,68 @@ +package configuration + +import ( + "strings" + + "go.maximumdirect.net/configkit" + "go.maximumdirect.net/promptkit/internal/domain" +) + +func resolvePrompts(entries []promptConfig, base configkit.Path, resolution *configkit.Resolution) { + if !eligibleResourceList(entries, func(v promptConfig) string { return v.ID }) { + return + } + for i := range entries { + entry := &entries[i] + path := base.Index(i) + for _, field := range []struct { + value **string + name string + }{{&entry.Description, "description"}, {&entry.SessionID, "session_id"}} { + if *field.value != nil { + recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value)) + } + } + for j := range entry.Inputs { + input := &entry.Inputs[j] + inputPath := path.Key("inputs").Index(j) + if normalized := strings.TrimSpace(input.Name); normalized != input.Name { + configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalized, "Normalize input name") + } + for _, field := range []struct { + value **string + name string + }{{&input.ContentType, "content_type"}, {&input.Description, "description"}} { + if *field.value != nil { + recordStringNormalization(field.value, inputPath.Key(field.name), resolution, strings.TrimSpace(**field.value)) + } + } + } + if entry.Output != nil && entry.Output.SchemaPath != nil { + recordStringNormalization(&entry.Output.SchemaPath, path.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*entry.Output.SchemaPath)) + } + for j := range entry.Messages { + message := &entry.Messages[j] + messagePath := path.Key("messages").Index(j) + if message.Role != nil { + if value, err := domain.NormalizeMessageRole(*message.Role); err == nil { + recordStringNormalization(&message.Role, messagePath.Key("role"), resolution, value) + } + } + if message.CacheControl == nil || message.CacheControl.Type == nil { + continue + } + cache := message.CacheControl + control := &domain.CacheControl{Type: domain.CacheControlType(*cache.Type)} + if cache.TTL != nil { + control.TTL = *cache.TTL + } + if normalized, err := domain.NormalizeCacheControl(control); err == nil { + cachePath := messagePath.Key("cache_control") + recordStringNormalization(&cache.Type, cachePath.Key("type"), resolution, string(normalized.Type)) + if cache.TTL != nil { + recordStringNormalization(&cache.TTL, cachePath.Key("ttl"), resolution, normalized.TTL) + } + } + } + } +}