Preserve prompt origins while interpreting metadata

This commit is contained in:
2026-11-02 12:47:10 +00:00
parent eaca50763b
commit afcfae2aea
5 changed files with 78 additions and 48 deletions

View File

@@ -613,6 +613,15 @@ inspection, and redaction tests pass without whole-list normalization. Complete
the backend/profile/prompt recovery before continuing the existing integration
work. No upstream release, extra public API, or provenance repair layer is needed.
Completion: prompt descriptions, sessions, inputs, and schema paths are now
interpreted on detached records; materialization retains the composed list paths
for source-relative content. The inherited/imported content regression now covers
trimmed metadata, replacement from another directory, retained origins, and
post-construction capture. Focused prompt and target interpretation tests passed
on Go 1.27.0, including nested ownership and duplicate normalized input names.
Production configuration code no longer records mutations to resource lists.
The broader facade inspection suite remains dependent on later fixture migration.
## Stage 13: Verify Identified Composition And Inheritance
Scope: `internal/configuration/load_test.go`, `targets_test.go`, and focused

View File

@@ -2,6 +2,7 @@ package configuration
import (
"context"
"strings"
"testing"
"go.maximumdirect.net/configkit"
@@ -44,3 +45,28 @@ func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) {
t.Fatalf("nested field origin changed: %+v", field)
}
}
func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(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"}},
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": " schema.json "},
}
state, err := load(mapping("prompt metadata", map[string]any{"prompts": []any{definition}}), nil)
if err != nil {
t.Fatal(err)
}
selected, inspection, err := state.prompt("example")
if err != nil || selected.Validation.SchemaPath != "schema.json" || inspection.OutputContract.SchemaPath != "schema.json" || selected.Inputs[0].Name != "document" {
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)
}
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)
if err == nil || !strings.Contains(err.Error(), "/prompts/0/inputs/1") || !strings.Contains(err.Error(), "duplicated") {
t.Fatalf("duplicate normalized names were not rejected: %v", err)
}
}

View File

@@ -246,7 +246,7 @@ func configurationDefinition(state *loaded) configkit.Definition[document] {
return nil
},
Validate: func(config *document, validation *configkit.Validation) {
state.interpreted = interpretTargets(config, validation)
state.interpreted = interpretConfiguration(config, validation)
validateEffective(&state.interpreted, validation)
},
}
@@ -381,18 +381,11 @@ func sortedKeys[T any](entries map[string]T) []string {
return keys
}
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 interpretTargets(config *document, validation *configkit.Validation) document {
func interpretConfiguration(config *document, validation *configkit.Validation) document {
interpreted := *config
interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation)
interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation)
interpreted.Prompts = interpretPrompts(config.Prompts)
if config.Collections != nil {
interpreted.Collections = make(map[string]collectionConfig, len(config.Collections))
for namespace, original := range config.Collections {
@@ -400,6 +393,7 @@ func interpretTargets(config *document, validation *configkit.Validation) docume
base := configkit.Keys("collections", namespace)
collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation)
collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation)
collection.Prompts = interpretPrompts(original.Prompts)
interpreted.Collections[namespace] = collection
}
}

View File

@@ -28,50 +28,42 @@ type promptEntry struct {
messages []*template.Template
}
func resolvePrompts(prompts []promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
func interpretPrompts(source []promptConfig) []promptConfig {
if source == nil {
return nil
}
prompts := append([]promptConfig{}, source...)
for index := range prompts {
definition := &prompts[index]
base := basePath.Index(index)
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))
for _, value := range []**string{&definition.Description, &definition.SessionID} {
if *value != nil {
normalized := strings.TrimSpace(**value)
*value = &normalized
}
}
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")
}
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")
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
}
}
}
}
// 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))
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 {
@@ -84,7 +76,7 @@ func (state *loaded) materializePrompts() error {
}
return metadata.Lookup(path)
}
for _, resources := range resourceSets(&state.result.Config) {
for _, resources := range resourceSets(&state.interpreted) {
for index, raw := range resources.prompts {
promptBase := resources.bases.prompts.Index(index)
if strings.TrimSpace(raw.ID) == "" {

View File

@@ -16,9 +16,9 @@ import (
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
files := fstest.MapFS{
"app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\n - path: ../other/definition.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
"app/main.yaml": {Data: []byte("config:\n imports:\n - path: ../catalog/base.yaml\n required: true\ncollections:\n docs:\n prompts:\n - id: child\n extends: {id: base}\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
"app/body.md": {Data: []byte("Replacement {{.name}}")},
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n - id: base\n description: ' description '\n session_id: ' session '\n inputs:\n - name: ' document '\n content_type: ' text/plain '\n description: ' document source '\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n - id: child\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: none\n")},
"other/body.md": {Data: []byte("Other collection {{.name}}")},
@@ -27,7 +27,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
state, err := load(configkit.Input{
Source: namespace.File("app/main.yaml"), Imports: true,
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
}, nil)
}, []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("other/definition.yaml"), Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."}}}}})
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
@@ -42,6 +42,10 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
}
if definition.Description != "description" || definition.SessionID != "session" || len(definition.Inputs) != 1 ||
definition.Inputs[0].Name != "document" || definition.Inputs[0].ContentType != "text/plain" || definition.Inputs[0].Description != "document source" {
t.Fatalf("inherited metadata was not interpreted: %+v", definition)
}
message := definition.Templates[0]
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
t.Fatalf("message metadata was not normalized: %+v", message)
@@ -55,6 +59,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
if childIndex < 0 {
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)
}
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 {
t.Fatalf("content origin was lost during normalization: %+v", field)