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.
|
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.
|
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
|
## Stage 7: Preserve Inspection And Sensitive-Value Handling
|
||||||
|
|
||||||
Scope: `internal/configuration/inspection.go`, configuration inspection/diff
|
Scope: `internal/configuration/inspection.go`, configuration inspection/diff
|
||||||
|
|||||||
@@ -323,11 +323,11 @@ type promptInputPresence struct {
|
|||||||
description fieldState
|
description fieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
func capturePromptMetadata(prompts []promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) {
|
||||||
for _, id := range sortedKeys(prompts) {
|
for promptIndex, prompt := range prompts {
|
||||||
inputs := prompts[id].Inputs
|
inputs := prompt.Inputs
|
||||||
states := make([]promptInputPresence, len(inputs))
|
states := make([]promptInputPresence, len(inputs))
|
||||||
base := basePath.Key(id).Key("inputs")
|
base := basePath.Index(promptIndex).Key("inputs")
|
||||||
for index := range inputs {
|
for index := range inputs {
|
||||||
path := base.Index(index)
|
path := base.Index(index)
|
||||||
states[index] = promptInputPresence{
|
states[index] = promptInputPresence{
|
||||||
@@ -335,7 +335,7 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P
|
|||||||
description: captureFieldState(path.Key("description"), resolution),
|
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
|
messages []*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
func resolvePrompts(prompts []promptConfig, basePath configkit.Path, resolution *configkit.Resolution) {
|
||||||
for _, id := range sortedKeys(prompts) {
|
for index := range prompts {
|
||||||
definition := prompts[id]
|
definition := &prompts[index]
|
||||||
base := basePath.Key(id)
|
base := basePath.Index(index)
|
||||||
for _, field := range []struct {
|
for _, field := range []struct {
|
||||||
name string
|
name string
|
||||||
value **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))
|
normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inputsChanged := false
|
|
||||||
for index := range definition.Inputs {
|
for index := range definition.Inputs {
|
||||||
input := &definition.Inputs[index]
|
input := &definition.Inputs[index]
|
||||||
inputPath := base.Key("inputs").Index(index)
|
inputPath := base.Key("inputs").Index(index)
|
||||||
normalizedName := strings.TrimSpace(input.Name)
|
normalizedName := strings.TrimSpace(input.Name)
|
||||||
if normalizedName != input.Name {
|
if normalizedName != input.Name {
|
||||||
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
configkit.Normalize(resolution, inputPath.Key("name"), &input.Name, normalizedName, "normalize input metadata")
|
||||||
inputsChanged = true
|
|
||||||
}
|
}
|
||||||
for _, field := range []struct {
|
for _, field := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -63,21 +61,16 @@ func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, re
|
|||||||
normalized := strings.TrimSpace(**field.value)
|
normalized := strings.TrimSpace(**field.value)
|
||||||
if normalized != **field.value {
|
if normalized != **field.value {
|
||||||
configkit.Normalize(resolution, inputPath.Key(field.name), field.value, &normalized, "normalize input metadata")
|
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
|
// Normalize message metadata on execution-owned copies below. ConfigKit
|
||||||
// records sequence mutations as whole-value replacements, which would
|
// records sequence mutations as whole-value replacements, which would
|
||||||
// discard the effective source contexts of unchanged content_file fields.
|
// discard the effective source contexts of unchanged content_file fields.
|
||||||
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
if definition.Output != nil && definition.Output.SchemaPath != nil {
|
||||||
normalizeString(&definition.Output.SchemaPath, base.Key("output").Key("schema_path"), resolution, strings.TrimSpace(*definition.Output.SchemaPath))
|
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)
|
return metadata.Lookup(path)
|
||||||
}
|
}
|
||||||
for _, resources := range resourceSets(&state.result.Config) {
|
for _, resources := range resourceSets(&state.result.Config) {
|
||||||
for _, id := range sortedKeys(resources.prompts) {
|
for index, raw := range resources.prompts {
|
||||||
promptBase := resources.bases.prompts.Key(id)
|
promptBase := resources.bases.prompts.Index(index)
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(raw.ID) == "" {
|
||||||
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
return fmt.Errorf("%s: prompt ID must not be blank", promptBase)
|
||||||
}
|
}
|
||||||
fullID := indexedResourceID(resources.namespace, id)
|
fullID := indexedResourceID(resources.namespace, raw.ID)
|
||||||
raw := resources.prompts[id]
|
entry, err := state.materializePrompt(fullID, raw, promptBase, lookupOrigin)
|
||||||
entry, err := state.materializePrompt(fullID, raw.Version, raw, promptBase, lookupOrigin)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,11 +101,11 @@ func (state *loaded) materializePrompts() error {
|
|||||||
return nil
|
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 {
|
if len(raw.Messages) == 0 {
|
||||||
return nil, fmt.Errorf("%s: at least one message is required", base.Key("messages"))
|
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 raw.DefaultProfile != nil {
|
||||||
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
if strings.TrimSpace(*raw.DefaultProfile) == "" {
|
||||||
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
|
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.definition = definition
|
||||||
entry.hash = hash
|
entry.hash = hash
|
||||||
entry.inspection = domain.PromptInspection{
|
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...),
|
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
|
||||||
OutputContract: contract,
|
OutputContract: contract,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ 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 child:\n extends: base\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")},
|
||||||
"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/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}}")},
|
"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}}")},
|
"other/body.md": {Data: []byte("Other collection {{.name}}")},
|
||||||
}
|
}
|
||||||
namespace := configkit.NewFilesystem("embedded", files)
|
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" {
|
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)
|
||||||
}
|
}
|
||||||
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 {
|
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)
|
||||||
}
|
}
|
||||||
@@ -59,10 +69,11 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) {
|
|||||||
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
t.Fatalf("content origin = %q, want catalog/base.yaml", originPath)
|
||||||
}
|
}
|
||||||
other, otherInspection, err := state.prompt("other/child")
|
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)
|
t.Fatalf("other collection did not retain its source context: %+v, %v", other, err)
|
||||||
}
|
}
|
||||||
files["catalog/body.md"].Data = []byte("Changed")
|
files["catalog/body.md"].Data = []byte("Changed")
|
||||||
|
files["app/body.md"].Data = []byte("Changed replacement")
|
||||||
files["other/body.md"].Data = []byte("Changed other collection")
|
files["other/body.md"].Data = []byte("Changed other collection")
|
||||||
again, againInspection, err := state.prompt("docs/child")
|
again, againInspection, err := state.prompt("docs/child")
|
||||||
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
|
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)
|
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
|
||||||
}
|
}
|
||||||
otherAgain, otherAgainInspection, err := state.prompt("other/child")
|
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)
|
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)
|
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)
|
_, 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)
|
t.Fatalf("expected semantic path for contextless resource, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,7 @@ func TestLoadCapturesNativeContentFile(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
definitionPath := filepath.Join(root, "definition.yaml")
|
definitionPath := filepath.Join(root, "definition.yaml")
|
||||||
contentPath := filepath.Join(root, "body.md")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(contentPath, []byte("Native body"), 0o600); err != nil {
|
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 {
|
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}},
|
"messages": []any{map[string]any{"role": "user", "content_file": name}},
|
||||||
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
"output": map[string]any{"format": "text", "validation_mode": "basic"},
|
||||||
}}}
|
}}}
|
||||||
|
|||||||
Reference in New Issue
Block a user