Capture prompts from identified lists
This commit is contained in:
@@ -330,6 +330,14 @@ Validation: owning prompt tests prove correct content and input metadata after
|
||||
inheritance/replacement, including file edits not changing a constructed engine.
|
||||
Do not normalize a whole list solely to update a field and lose its origins.
|
||||
|
||||
Completion: prompt capture, normalization, input presence, and materialization
|
||||
now use effective list indexes and full runtime IDs, preserving the shared lazy
|
||||
origin snapshot. Converted native, explicit-context, inherited-import, and
|
||||
replacement content fixtures. Focused execution is deferred because older
|
||||
loader tests still index resource maps and reference removed prompt metadata;
|
||||
their conversion is assigned to later work. Formatting and `git diff --check`
|
||||
passed.
|
||||
|
||||
## Stage 7: Preserve Inspection And Sensitive-Value Handling
|
||||
|
||||
Scope: `internal/configuration/inspection.go`, configuration inspection/diff
|
||||
|
||||
@@ -323,11 +323,11 @@ type promptInputPresence struct {
|
||||
description fieldState
|
||||
}
|
||||
|
||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||
for _, id := range sortedKeys(prompts) {
|
||||
inputs := prompts[id].Inputs
|
||||
func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||
for promptIndex, prompt := range prompts {
|
||||
inputs := prompt.Inputs
|
||||
states := make([]promptInputPresence, len(inputs))
|
||||
base := basePath.Key(id).Key("inputs")
|
||||
base := basePath.Index(promptIndex).Key("inputs")
|
||||
for index := range inputs {
|
||||
path := base.Index(index)
|
||||
states[index] = promptInputPresence{
|
||||
@@ -335,7 +335,7 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P
|
||||
description: captureFieldState(path.Key("description"), resolution),
|
||||
}
|
||||
}
|
||||
destination[indexedResourceID(namespace, id)] = states
|
||||
destination[indexedResourceID(namespace, prompt.ID)] = states
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ type promptEntry struct {
|
||||
messages []*template.Template
|
||||
}
|
||||
|
||||
func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||
for _, id := range sortedKeys(prompts) {
|
||||
definition := prompts[id]
|
||||
base := basePath.Key(id)
|
||||
func resolvePrompts(prompts []promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||
for index := range prompts {
|
||||
definition := &prompts[index]
|
||||
base := basePath.Index(index)
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value **string
|
||||
@@ -43,14 +43,12 @@ func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, re
|
||||
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||
}
|
||||
}
|
||||
inputsChanged := false
|
||||
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")
|
||||
inputsChanged = true
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
@@ -63,21 +61,16 @@ func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, re
|
||||
normalized := strings.TrimSpace(**field.value)
|
||||
if normalized != **field.value {
|
||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata")
|
||||
inputsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if inputsChanged {
|
||||
resolution.RecordNormalization(base.Key("inputs"), "normalize input metadata")
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
prompts[id] = definition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,14 +85,13 @@ func (state *loaded) materializePrompts() error {
|
||||
return metadata.Lookup(path)
|
||||
}
|
||||
for _, resources := range resourceSets(&state.result.Config) {
|
||||
for _, id := range sortedKeys(resources.prompts) {
|
||||
promptBase := resources.bases.prompts.Key(id)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
for index, raw := range resources.prompts {
|
||||
promptBase := resources.bases.prompts.Index(index)
|
||||
if strings.TrimSpace(raw.ID) == "" {
|
||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||
}
|
||||
fullID := indexedResourceID(resources.namespace, id)
|
||||
raw := resources.prompts[id]
|
||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
||||
fullID := indexedResourceID(resources.namespace, raw.ID)
|
||||
entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,11 +101,11 @@ func (state *loaded) materializePrompts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *loaded) materializePrompt(id string, version *string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||
func (state *loaded) materializePrompt(id string, raw promptConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) (*promptEntry, error) {
|
||||
if len(raw.Messages) == 0 {
|
||||
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
||||
}
|
||||
definition := domain.PromptDefinition{ID: id, Version: stringPointerValue(version)}
|
||||
definition := domain.PromptDefinition{ID: id}
|
||||
if raw.DefaultProfile != nil {
|
||||
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
||||
@@ -242,7 +234,7 @@ func (state *loaded) materializePrompt(id string, version *string, raw promptCon
|
||||
entry.definition = definition
|
||||
entry.hash = hash
|
||||
entry.inspection = domain.PromptInspection{
|
||||
PromptID: id, PromptVersion: stringPointerValue(version), PromptHash: hash,
|
||||
PromptID: id, PromptHash: hash,
|
||||
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
||||
OutputContract: contract,
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ 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 child:\n extends: base\n")},
|
||||
"catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\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")},
|
||||
"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/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/body.md": {Data: []byte("Captured {{.name}}")},
|
||||
"other/definition.yaml": {Data: []byte("collections:\n other:\n prompts:\n 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}}")},
|
||||
}
|
||||
namespace := configkit.NewFilesystem("embedded", files)
|
||||
@@ -45,7 +46,16 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" {
|
||||
t.Fatalf("message metadata was not normalized: %+v", message)
|
||||
}
|
||||
field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file"))
|
||||
childIndex := -1
|
||||
for index, item := range state.result.Config.Collections["docs"].Prompts {
|
||||
if item.ID == "child" {
|
||||
childIndex = index
|
||||
}
|
||||
}
|
||||
if childIndex < 0 {
|
||||
t.Fatal("inherited prompt missing from effective list")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -59,10 +69,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
||||
}
|
||||
other, otherInspection, err := state.prompt("other/child")
|
||||
if err != nil || other.Templates[0].Content != "Other collection {{.name}}" {
|
||||
if err != nil || other.Templates[0].Content != "Replacement {{.name}}" {
|
||||
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
||||
}
|
||||
files["catalog/body.md"].Data = []byte("Changed")
|
||||
files["app/body.md"].Data = []byte("Changed replacement")
|
||||
files["other/body.md"].Data = []byte("Changed other collection")
|
||||
again, againInspection, err := state.prompt("docs/child")
|
||||
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
||||
@@ -74,7 +85,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
||||
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
||||
}
|
||||
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
||||
if err != nil || otherAgain.Templates[0].Content != "Other collection {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
||||
if err != nil || otherAgain.Templates[0].Content != "Replacement {{.name}}" || otherAgainInspection.PromptHash != otherInspection.PromptHash {
|
||||
t.Fatalf("other captured definition changed after source mutation: %+v, %+v, %v", otherAgain, otherAgainInspection, err)
|
||||
}
|
||||
}
|
||||
@@ -97,7 +108,7 @@ func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
|
||||
t.Fatalf("mapping context or handle ownership failed: %+v, opens=%d closes=%d, %v", definition, filesystem.contentOpened, filesystem.contentClosed, err)
|
||||
}
|
||||
_, err = load(mapping("contextless", promptWithFile("body.md")), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "/prompts/example/messages/0/content_file") {
|
||||
if err == nil || !strings.Contains(err.Error(), "/prompts/0/messages/0/content_file") {
|
||||
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +117,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
definitionPath := filepath.Join(root, "definition.yaml")
|
||||
contentPath := filepath.Join(root, "body.md")
|
||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n example:\n version: \"v1\"\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(definitionPath, []byte("prompts:\n - id: example\n messages:\n - role: user\n content_file: body.md\n output:\n format: text\n validation_mode: basic\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
||||
@@ -466,7 +477,8 @@ func TestInputMetadataPresenceDoesNotChangePromptHashOrInspection(t *testing.T)
|
||||
}
|
||||
|
||||
func promptWithFile(name string) map[string]any {
|
||||
return map[string]any{"prompts": map[string]any{"example": map[string]any{
|
||||
return map[string]any{"prompts": []any{map[string]any{
|
||||
"id": "example",
|
||||
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||
}}}
|
||||
|
||||
Reference in New Issue
Block a user