Preserve prompt origins while interpreting metadata
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user