diff --git a/docs/consumers/README.md b/docs/consumers/README.md index cd58920..5a79f90 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -185,11 +185,26 @@ import, source-location, and ownership behavior. ## Identified prompt sequences [`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one -definition registration for the literal `prompts` path and `id` field. Its main -file imports one prompt by path and others by glob, placing each source as an -item. A nested ordinary helper import supplies an item's ID. The main tier -strictly appends the distinct records in contribution order; its completed -sequence replaces the lower-priority default collection as one value. +definition registration for the literal `prompts` path and `id` field. Its +load-wide filename defaults accept two patterns, an explicit directory +declaration overrides those patterns, and an exclusion omits one matching +candidate. A nested ordinary helper import supplies an item's ID. A separate +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 prompt names and message lists satisfy this consumer's requirements after diff --git a/examples/consumers/prompt_catalog.go b/examples/consumers/prompt_catalog.go index ac60d59..b2f7acb 100644 --- a/examples/consumers/prompt_catalog.go +++ b/examples/consumers/prompt_catalog.go @@ -16,9 +16,10 @@ type PromptRecord struct { ID string `yaml:"id"` Name string `yaml:"name"` 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. func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) { return configkit.LoadLayered(configkit.Input{ @@ -28,16 +29,30 @@ func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], 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")), }}}}, - Definition: configkit.Definition[PromptCatalogConfig]{ - SequenceCompositions: []configkit.SequenceComposition{{ - Path: configkit.Keys("prompts"), - IdentityField: "id", - }}, - Validate: validatePromptCatalog, + ImportDefaults: configkit.ImportDefaults{ + Pattern: []string{"curated*.yml", "generated-*.yml"}, }, + 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) { for index, prompt := range configuration.Prompts { if strings.TrimSpace(prompt.Name) == "" { diff --git a/examples/consumers/prompt_catalog_test.go b/examples/consumers/prompt_catalog_test.go index 06e2c66..b4f4d67 100644 --- a/examples/consumers/prompt_catalog_test.go +++ b/examples/consumers/prompt_catalog_test.go @@ -3,6 +3,8 @@ package consumers_test import ( "errors" "fmt" + "io/fs" + pathpkg "path" "path/filepath" "testing" @@ -20,8 +22,26 @@ func Example_identifiedPromptCatalog() { for _, prompt := range result.Config.Prompts { ids = append(ids, prompt.ID) } - fmt.Println(ids, err) - // Output: [main curated weather welcome] + emptySearches := 0 + 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 +} + +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 } func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) { @@ -36,7 +56,7 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) { if len(result.Config.Prompts) != 4 { 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 { if 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" { 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 for _, source := range result.Sources() { 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" { 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) { _, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml")) var loadError *configkit.LoadError diff --git a/examples/consumers/testdata/prompt-catalog/config.yml b/examples/consumers/testdata/prompt-catalog/config.yml index 0b786e0..e59e761 100644 --- a/examples/consumers/testdata/prompt-catalog/config.yml +++ b/examples/consumers/testdata/prompt-catalog/config.yml @@ -1,11 +1,14 @@ configkit: imports: - - path: prompts/curated.yml + - dir: prompts + exclude: [generated-ignored.yml] destination: [prompts] placement: sequence-item - - glob: prompts/generated-*.yml + - dir: featured + pattern: [weather-*.yml] destination: [prompts] placement: sequence-item + - dir: optional prompts: - id: main name: Main authored prompt diff --git a/examples/consumers/testdata/prompt-catalog/prompts/generated-weather.yml b/examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml similarity index 100% rename from examples/consumers/testdata/prompt-catalog/prompts/generated-weather.yml rename to examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml diff --git a/examples/consumers/testdata/prompt-catalog/optional/ignored.txt b/examples/consumers/testdata/prompt-catalog/optional/ignored.txt new file mode 100644 index 0000000..d56caf0 --- /dev/null +++ b/examples/consumers/testdata/prompt-catalog/optional/ignored.txt @@ -0,0 +1 @@ +This file does not match the application import defaults. diff --git a/examples/consumers/testdata/prompt-catalog/prompts/curated.yml b/examples/consumers/testdata/prompt-catalog/prompts/curated.yml index e554c31..7cc3140 100644 --- a/examples/consumers/testdata/prompt-catalog/prompts/curated.yml +++ b/examples/consumers/testdata/prompt-catalog/prompts/curated.yml @@ -1,6 +1,6 @@ configkit: imports: - - path: curated-id.yml + - path: helpers/curated-id.yml name: Curated summary messages: - Summarize the supplied content. diff --git a/examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml b/examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml new file mode 100644 index 0000000..146b5b5 --- /dev/null +++ b/examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml @@ -0,0 +1,3 @@ +id: ignored +name: Excluded generated prompt +messages: [This record is excluded.] diff --git a/examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml b/examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml index c6d0851..ce4170f 100644 --- a/examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml +++ b/examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml @@ -2,3 +2,4 @@ id: welcome name: Welcome messages: - Welcome the user. +resource: welcome.txt diff --git a/examples/consumers/testdata/prompt-catalog/prompts/curated-id.yml b/examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml similarity index 100% rename from examples/consumers/testdata/prompt-catalog/prompts/curated-id.yml rename to examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml diff --git a/examples/consumers/testdata/prompt-catalog/prompts/required.yml b/examples/consumers/testdata/prompt-catalog/prompts/required.yml new file mode 100644 index 0000000..fd7ed67 --- /dev/null +++ b/examples/consumers/testdata/prompt-catalog/prompts/required.yml @@ -0,0 +1,3 @@ +id: required +name: Required prompt +messages: [Use the required prompt.] diff --git a/examples/consumers/testdata/prompt-catalog/prompts/welcome.txt b/examples/consumers/testdata/prompt-catalog/prompts/welcome.txt new file mode 100644 index 0000000..b6603aa --- /dev/null +++ b/examples/consumers/testdata/prompt-catalog/prompts/welcome.txt @@ -0,0 +1 @@ +Welcome resource text. diff --git a/examples/consumers/testdata/prompt-catalog/simple.yml b/examples/consumers/testdata/prompt-catalog/simple.yml new file mode 100644 index 0000000..9dd0a16 --- /dev/null +++ b/examples/consumers/testdata/prompt-catalog/simple.yml @@ -0,0 +1,7 @@ +configkit: + imports: + - path: prompts/required.yml + required: true + destination: [prompts] + placement: sequence-item +prompts: []