diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index da12936..2375da9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/configuration/interpretation_test.go b/internal/configuration/interpretation_test.go index b467541..518b9a0 100644 --- a/internal/configuration/interpretation_test.go +++ b/internal/configuration/interpretation_test.go @@ -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) + } +} diff --git a/internal/configuration/load.go b/internal/configuration/load.go index d22da3a..835dad1 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -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 } } diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go index 7d127de..80754e5 100644 --- a/internal/configuration/prompts.go +++ b/internal/configuration/prompts.go @@ -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) == "" { diff --git a/internal/configuration/prompts_test.go b/internal/configuration/prompts_test.go index 9748e73..1bc0a4c 100644 --- a/internal/configuration/prompts_test.go +++ b/internal/configuration/prompts_test.go @@ -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)