Expand prompt catalog discovery example

This commit is contained in:
2026-10-19 21:15:47 +00:00
parent c00811da2e
commit de3666aed3
13 changed files with 121 additions and 19 deletions

View File

@@ -185,11 +185,26 @@ import, source-location, and ownership behavior.
## Identified prompt sequences ## Identified prompt sequences
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one [`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
definition registration for the literal `prompts` path and `id` field. Its main definition registration for the literal `prompts` path and `id` field. Its
file imports one prompt by path and others by glob, placing each source as an load-wide filename defaults accept two patterns, an explicit directory
item. A nested ordinary helper import supplies an item's ID. The main tier declaration overrides those patterns, and an exclusion omits one matching
strictly appends the distinct records in contribution order; its completed candidate. A nested ordinary helper import supplies an item's ID. A separate
sequence replaces the lower-priority default collection as one value. empty optional directory search is read from `SourceInfo.ImportSelections`.
The executable example prints both the scheduled prompt order and the single
empty-search outcome; the behavior test also follows a mounted resource through
its effective metadata origin to the declaring source context. The completed
main sequence replaces the lower-priority default collection as one value.
The maintained [executable example and behavior tests](../../examples/consumers/prompt_catalog_test.go)
show the scheduled result, empty-search inspection, excluded candidate, and
resource lookup without a consumer-side discovery pass. The corresponding
[complex fixture](../../examples/consumers/testdata/prompt-catalog/config.yml)
and [simple required-file fixture](../../examples/consumers/testdata/prompt-catalog/simple.yml)
are complete local examples.
[`LoadRequiredPromptCatalog`](../../examples/consumers/prompt_catalog.go) and
[`Example_requiredPromptCatalog`](../../examples/consumers/prompt_catalog_test.go)
keep the smaller adoption path visible: one explicitly required file import,
the same identity registration, and the same application validation.
The example's `validatePromptCatalog` remains application-owned. It checks that The example's `validatePromptCatalog` remains application-owned. It checks that
prompt names and message lists satisfy this consumer's requirements after prompt names and message lists satisfy this consumer's requirements after

View File

@@ -16,9 +16,10 @@ type PromptRecord struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Name string `yaml:"name"` Name string `yaml:"name"`
Messages []string `yaml:"messages"` Messages []string `yaml:"messages"`
Resource string `yaml:"resource"`
} }
// LoadPromptCatalog composes explicitly registered prompt records in the main // LoadPromptCatalog discovers explicitly registered prompt records in the main
// tier and replaces the lower-priority default collection as a whole. // tier and replaces the lower-priority default collection as a whole.
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) { func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
return configkit.LoadLayered(configkit.Input{ return configkit.LoadLayered(configkit.Input{
@@ -28,16 +29,30 @@ func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig],
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")), Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
}}}}, }}}},
Definition: configkit.Definition[PromptCatalogConfig]{ ImportDefaults: configkit.ImportDefaults{
SequenceCompositions: []configkit.SequenceComposition{{ Pattern: []string{"curated*.yml", "generated-*.yml"},
Path: configkit.Keys("prompts"),
IdentityField: "id",
}},
Validate: validatePromptCatalog,
}, },
Definition: promptCatalogDefinition(),
}) })
} }
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
Imports: true, Definition: promptCatalogDefinition(),
})
}
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
return configkit.Definition[PromptCatalogConfig]{
SequenceCompositions: []configkit.SequenceComposition{{
Path: configkit.Keys("prompts"),
IdentityField: "id",
}},
Validate: validatePromptCatalog,
}
}
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) { func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
for index, prompt := range configuration.Prompts { for index, prompt := range configuration.Prompts {
if strings.TrimSpace(prompt.Name) == "" { if strings.TrimSpace(prompt.Name) == "" {

View File

@@ -3,6 +3,8 @@ package consumers_test
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/fs"
pathpkg "path"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -20,8 +22,26 @@ func Example_identifiedPromptCatalog() {
for _, prompt := range result.Config.Prompts { for _, prompt := range result.Config.Prompts {
ids = append(ids, prompt.ID) ids = append(ids, prompt.ID)
} }
fmt.Println(ids, err) emptySearches := 0
// Output: [main curated weather welcome] <nil> for _, source := range result.Sources() {
for _, selection := range source.ImportSelections {
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
emptySearches++
}
}
}
fmt.Println(ids, emptySearches, err)
// Output: [weather curated welcome main] 1 <nil>
}
func Example_requiredPromptCatalog() {
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result.Config.Prompts[0].ID, err)
// Output: required <nil>
} }
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) { func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
@@ -36,7 +56,7 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
if len(result.Config.Prompts) != 4 { if len(result.Config.Prompts) != 4 {
t.Fatalf("prompt records = %#v", result.Config.Prompts) t.Fatalf("prompt records = %#v", result.Config.Prompts)
} }
ids := []string{"main", "curated", "weather", "welcome"} ids := []string{"weather", "curated", "welcome", "main"}
for index, id := range ids { for index, id := range ids {
if result.Config.Prompts[index].ID != id { if result.Config.Prompts[index].ID != id {
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id) t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
@@ -46,12 +66,15 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
if prompt.ID == "default" { if prompt.ID == "default" {
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts) t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
} }
if prompt.ID == "ignored" {
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
}
} }
var itemImports, helperImports int var itemImports, helperImports int
for _, source := range result.Sources() { for _, source := range result.Sources() {
switch filepath.Base(source.Path) { switch filepath.Base(source.Path) {
case "curated.yml", "generated-weather.yml", "generated-welcome.yml": case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" { if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
t.Fatalf("item source placement = %#v", source) t.Fatalf("item source placement = %#v", source)
} }
@@ -71,6 +94,36 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
} }
} }
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
if err != nil {
t.Fatal(err)
}
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
}
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
info, ok := result.Metadata().Lookup(resourcePath)
if !ok || len(info.Origin.Locations) != 1 {
t.Fatalf("resource origin = %#v, %v", info, ok)
}
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
t.Fatalf("resource source context = %#v, %v", context, ok)
}
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
if err != nil || string(contents) != "Welcome resource text.\n" {
t.Fatalf("mounted resource = %q, %v", contents, err)
}
}
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
t.Fatalf("simple required prompt = %#v, %v", result, err)
}
}
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) { func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml")) _, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
var loadError *configkit.LoadError var loadError *configkit.LoadError

View File

@@ -1,11 +1,14 @@
configkit: configkit:
imports: imports:
- path: prompts/curated.yml - dir: prompts
exclude: [generated-ignored.yml]
destination: [prompts] destination: [prompts]
placement: sequence-item placement: sequence-item
- glob: prompts/generated-*.yml - dir: featured
pattern: [weather-*.yml]
destination: [prompts] destination: [prompts]
placement: sequence-item placement: sequence-item
- dir: optional
prompts: prompts:
- id: main - id: main
name: Main authored prompt name: Main authored prompt

View File

@@ -0,0 +1 @@
This file does not match the application import defaults.

View File

@@ -1,6 +1,6 @@
configkit: configkit:
imports: imports:
- path: curated-id.yml - path: helpers/curated-id.yml
name: Curated summary name: Curated summary
messages: messages:
- Summarize the supplied content. - Summarize the supplied content.

View File

@@ -0,0 +1,3 @@
id: ignored
name: Excluded generated prompt
messages: [This record is excluded.]

View File

@@ -2,3 +2,4 @@ id: welcome
name: Welcome name: Welcome
messages: messages:
- Welcome the user. - Welcome the user.
resource: welcome.txt

View File

@@ -0,0 +1,3 @@
id: required
name: Required prompt
messages: [Use the required prompt.]

View File

@@ -0,0 +1 @@
Welcome resource text.

View File

@@ -0,0 +1,7 @@
configkit:
imports:
- path: prompts/required.yml
required: true
destination: [prompts]
placement: sequence-item
prompts: []