Record prompt field normalization
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
internal/configuration/resolve_prompts.go
Normal file
68
internal/configuration/resolve_prompts.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user