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
|
the backend/profile/prompt recovery before continuing the existing integration
|
||||||
work. No upstream release, extra public API, or provenance repair layer is needed.
|
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
|
## Stage 13: Verify Identified Composition And Inheritance
|
||||||
|
|
||||||
Scope: `internal/configuration/load_test.go`, `targets_test.go`, and focused
|
Scope: `internal/configuration/load_test.go`, `targets_test.go`, and focused
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package configuration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.maximumdirect.net/configkit"
|
"go.maximumdirect.net/configkit"
|
||||||
@@ -44,3 +45,28 @@ func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) {
|
|||||||
t.Fatalf("nested field origin changed: %+v", field)
|
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
|
return nil
|
||||||
},
|
},
|
||||||
Validate: func(config *document, validation *configkit.Validation) {
|
Validate: func(config *document, validation *configkit.Validation) {
|
||||||
state.interpreted = interpretTargets(config, validation)
|
state.interpreted = interpretConfiguration(config, validation)
|
||||||
validateEffective(&state.interpreted, validation)
|
validateEffective(&state.interpreted, validation)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -381,18 +381,11 @@ func sortedKeys[T any](entries map[string]T) []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) bool {
|
func interpretConfiguration(config *document, validation *configkit.Validation) document {
|
||||||
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 {
|
|
||||||
interpreted := *config
|
interpreted := *config
|
||||||
interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation)
|
interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation)
|
||||||
interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation)
|
interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation)
|
||||||
|
interpreted.Prompts = interpretPrompts(config.Prompts)
|
||||||
if config.Collections != nil {
|
if config.Collections != nil {
|
||||||
interpreted.Collections = make(map[string]collectionConfig, len(config.Collections))
|
interpreted.Collections = make(map[string]collectionConfig, len(config.Collections))
|
||||||
for namespace, original := range 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)
|
base := configkit.Keys("collections", namespace)
|
||||||
collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation)
|
collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation)
|
||||||
collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation)
|
collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation)
|
||||||
|
collection.Prompts = interpretPrompts(original.Prompts)
|
||||||
interpreted.Collections[namespace] = collection
|
interpreted.Collections[namespace] = collection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,50 +28,42 @@ type promptEntry struct {
|
|||||||
messages []*template.Template
|
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 {
|
for index := range prompts {
|
||||||
definition := &prompts[index]
|
definition := &prompts[index]
|
||||||
base := basePath.Index(index)
|
for _, value := range []**string{&definition.Description, &definition.SessionID} {
|
||||||
for _, field := range []struct {
|
if *value != nil {
|
||||||
name string
|
normalized := strings.TrimSpace(**value)
|
||||||
value **string
|
*value = &normalized
|
||||||
}{
|
|
||||||
{"description", &definition.Description},
|
|
||||||
{"session_id", &definition.SessionID},
|
|
||||||
} {
|
|
||||||
if *field.value != nil {
|
|
||||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for index := range definition.Inputs {
|
if definition.Inputs != nil {
|
||||||
input := &definition.Inputs[index]
|
definition.Inputs = append([]promptInputConfig{}, definition.Inputs...)
|
||||||
inputPath := base.Key("inputs").Index(index)
|
for index := range definition.Inputs {
|
||||||
normalizedName := strings.TrimSpace(input.Name)
|
input := &definition.Inputs[index]
|
||||||
if normalizedName != input.Name {
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
for _, value := range []**string{&input.ContentType, &input.Description} {
|
||||||
}
|
if *value != nil {
|
||||||
for _, field := range []struct {
|
normalized := strings.TrimSpace(**value)
|
||||||
name string
|
*value = &normalized
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Normalize message metadata on execution-owned copies below. ConfigKit
|
if definition.Output != nil {
|
||||||
// records sequence mutations as whole-value replacements, which would
|
output := *definition.Output
|
||||||
// discard the effective source contexts of unchanged content_file fields.
|
definition.Output = &output
|
||||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
if output.SchemaPath != nil {
|
||||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
normalized := strings.TrimSpace(*output.SchemaPath)
|
||||||
|
output.SchemaPath = &normalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return prompts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (state *loaded) materializePrompts() error {
|
func (state *loaded) materializePrompts() error {
|
||||||
@@ -84,7 +76,7 @@ func (state *loaded) materializePrompts() error {
|
|||||||
}
|
}
|
||||||
return metadata.Lookup(path)
|
return metadata.Lookup(path)
|
||||||
}
|
}
|
||||||
for _, resources := range resourceSets(&state.result.Config) {
|
for _, resources := range resourceSets(&state.interpreted) {
|
||||||
for index, raw := range resources.prompts {
|
for index, raw := range resources.prompts {
|
||||||
promptBase := resources.bases.prompts.Index(index)
|
promptBase := resources.bases.prompts.Index(index)
|
||||||
if strings.TrimSpace(raw.ID) == "" {
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
|
|
||||||
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
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}}")},
|
"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}}")},
|
"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/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}}")},
|
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
||||||
@@ -27,7 +27,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
state, err := load(configkit.Input{
|
state, err := load(configkit.Input{
|
||||||
Source: namespace.File("app/main.yaml"), Imports: true,
|
Source: namespace.File("app/main.yaml"), Imports: true,
|
||||||
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
|
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 {
|
if err != nil {
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if errors.As(err, &loadError) {
|
if errors.As(err, &loadError) {
|
||||||
@@ -42,6 +42,10 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
|
||||||
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
|
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]
|
message := definition.Templates[0]
|
||||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||||
t.Fatalf("message metadata was not normalized: %+v", message)
|
t.Fatalf("message metadata was not normalized: %+v", message)
|
||||||
@@ -55,6 +59,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
if childIndex < 0 {
|
if childIndex < 0 {
|
||||||
t.Fatal("inherited prompt missing from effective list")
|
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"))
|
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 {
|
if !ok || len(field.Origin.Locations) != 1 {
|
||||||
t.Fatalf("content origin was lost during normalization: %+v", field)
|
t.Fatalf("content origin was lost during normalization: %+v", field)
|
||||||
|
|||||||
Reference in New Issue
Block a user