Capture keyed prompts and source-backed content at load time

This commit is contained in:
2026-09-30 02:19:52 +00:00
parent 2b847ac2ae
commit b158a8ec2d
12 changed files with 722 additions and 17 deletions

78
configuration_sources.go Normal file
View File

@@ -0,0 +1,78 @@
package promptkit
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
)
// DirectoryInputs selects the YAML files beneath an explicitly chosen host
// directory as one strict-composition tier. The returned order is deterministic;
// it does not assign precedence among those files.
func DirectoryInputs(directory string) ([]configkit.Input, error) {
root, err := filepath.Abs(directory)
if err != nil {
return nil, fmt.Errorf("resolve configuration directory: %w", err)
}
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("inspect configuration directory: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("configuration root is not a directory: %s", root)
}
root, err = filepath.EvalSymlinks(root)
if err != nil {
return nil, fmt.Errorf("resolve configuration directory: %w", err)
}
files, err := filecatalog.FindYAMLFiles(context.Background(), root)
if err != nil {
return nil, fmt.Errorf("discover configuration files: %w", err)
}
inputs := make([]configkit.Input, 0, len(files))
for _, file := range files {
inputs = append(inputs, configkit.Input{
Source: configkit.File(file),
Filesystem: &configkit.FilesystemContext{Root: root},
})
}
return inputs, nil
}
// FilesystemInputs selects the YAML files beneath root in a caller-owned
// filesystem as one strict-composition tier. The caller retains ownership of
// the filesystem; imports remain disabled unless callers enable them on inputs.
func FilesystemInputs(label string, filesystem fs.FS, root string) ([]configkit.Input, error) {
if filesystem == nil {
return nil, fmt.Errorf("configuration filesystem is nil")
}
root = filecatalog.CleanFSRoot(root)
if !fs.ValidPath(root) {
return nil, fmt.Errorf("configuration root is not a valid filesystem path: %s", root)
}
info, err := fs.Stat(filesystem, root)
if err != nil {
return nil, fmt.Errorf("inspect configuration root: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("configuration root is not a directory: %s", root)
}
files, err := filecatalog.FindFSYAMLFiles(context.Background(), filesystem, root)
if err != nil {
return nil, fmt.Errorf("discover configuration files: %w", err)
}
namespace := configkit.NewFilesystem(label, filesystem)
inputs := make([]configkit.Input, 0, len(files))
for _, file := range files {
inputs = append(inputs, configkit.Input{
Source: namespace.File(file),
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: root},
})
}
return inputs, nil
}

View File

@@ -0,0 +1,83 @@
package promptkit_test
import (
"io/fs"
"os"
"path/filepath"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit"
)
type sourceDocument struct {
First string `yaml:"first"`
Second string `yaml:"second"`
}
func TestDirectoryInputsSelectsSortedFilesWithRootContext(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
t.Fatal(err)
}
for name, data := range map[string]string{
"a.yaml": "first: one\n", "nested/b.yml": "second: two\n", "nested/body.md": "message",
} {
if err := os.WriteFile(filepath.Join(root, name), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
inputs, err := promptkit.DirectoryInputs(root)
if err != nil || len(inputs) != 2 || inputs[0].Imports || inputs[1].Imports {
t.Fatalf("directory inputs: %+v, %v", inputs, err)
}
assertSourceInputs(t, inputs, "message")
if _, err := promptkit.DirectoryInputs(filepath.Join(root, "missing")); err == nil {
t.Fatal("missing selected directory succeeded")
}
if empty, err := promptkit.DirectoryInputs(t.TempDir()); err != nil || len(empty) != 0 {
t.Fatalf("empty directory: %+v, %v", empty, err)
}
}
func TestFilesystemInputsSelectsEmbeddedFilesWithRootContext(t *testing.T) {
files := fstest.MapFS{
"settings/a.yaml": {Data: []byte("first: one\n")},
"settings/nested/b.yml": {Data: []byte("second: two\n")},
"settings/nested/body.md": {Data: []byte("message")},
}
inputs, err := promptkit.FilesystemInputs("embedded", files, "settings")
if err != nil || len(inputs) != 2 || inputs[0].Imports || inputs[1].Imports {
t.Fatalf("filesystem inputs: %+v, %v", inputs, err)
}
assertSourceInputs(t, inputs, "message")
if _, err := promptkit.FilesystemInputs("embedded", files, "missing"); err == nil {
t.Fatal("missing selected root succeeded")
}
}
func assertSourceInputs(t *testing.T, inputs []configkit.Input, expected string) {
t.Helper()
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[sourceDocument]{
Underlays: []configkit.Underlay{{Inputs: inputs}},
})
if err != nil {
t.Fatal(err)
}
if result.Config.First != "one" || result.Config.Second != "two" {
t.Fatalf("configuration files did not compose: %+v", result.Config)
}
field, found := result.Metadata().Lookup(configkit.Keys("second"))
if !found || len(field.Origin.Locations) != 1 {
t.Fatalf("missing second-field origin: %+v", field)
}
context, found := result.SourceContext(field.Origin.Locations[0].SourceID)
if !found || context.BaseDir != "nested" {
t.Fatalf("unexpected rooted source context: %+v", context)
}
data, err := fs.ReadFile(context.FS, "nested/body.md")
if err != nil || string(data) != expected {
t.Fatalf("rooted resource read: %q, %v", data, err)
}
}

View File

@@ -17,7 +17,7 @@ contributor workflow and validation.
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
| `internal/backend` | Constructs each engine's immutable registry from maintained definitions and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
| `internal/catalog` | Strictly validates imported immutable maintained backend and profile catalog assets before engine assembly uses them. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) |
| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit, recording effective field presence and validating final backend/profile values. It is not yet connected to engine assembly. | [Typed loader](../../internal/configuration/load.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit; records effective field presence, validates final values, and captures indexed prompt definitions and referenced message content. It is not yet connected to engine assembly. | [Typed loader](../../internal/configuration/load.go), [internal sources](sources.md#typed-configuration-loading) |
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
| `internal/engine` | Assembles external catalogs, Promptkit-owned profiles, repositories, registry, capacity, collaborators, repairer, and the one use-case runner for each engine instance. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) |

View File

@@ -17,11 +17,16 @@ backend, profile, and prompt mappings. It performs one layered load, records
effective field presence (including explicit null), normalizes backend and
profile fields through ConfigKit resolution, and validates final backend and
profile domain values. A lower-layer domain value replaced before the handoff
is not separately validated. Its focused tests exercise real ConfigKit loading.
is not separately validated. It also validates effective prompt fields, reads
referenced message content using the contributing field's passive source
context, checks static template syntax, and indexes immutable definitions by
exact ID and version. Its focused tests exercise real ConfigKit loading.
This loader is not yet wired into engine assembly. The public engine continues
to use the source and repository behavior described below; prompt resource
capture and full prompt validation are subsequent implementation work.
to use the source and repository behavior described below. The public
`DirectoryInputs` and `FilesystemInputs` helpers select sorted YAML inputs for
one strict-composition group and attach an explicit resource root; they do not
assign precedence or enable imports.
## Prompt Definitions

View File

@@ -334,6 +334,8 @@ as migrated. Internal inventory documents only the loader now actually present.
## Stage 2 — Add Source Discovery And Prompt Resource Capture
**Completed:** 2026-09-30.
Scope: source selection helpers and construction-time prompt materialization.
1. Implement private directory/FS discovery behind the chosen helper signatures;

View File

@@ -89,9 +89,11 @@ const (
)
type loaded struct {
result *configkit.Result[document]
backends map[string]map[string]fieldState
profiles map[string]map[string]fieldState
result *configkit.Result[document]
backends map[string]map[string]fieldState
profiles map[string]map[string]fieldState
prompts map[string]map[string]*promptEntry
ambiguous map[string]bool
}
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
@@ -114,6 +116,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
captureFields(config.Profiles, "profiles", profileFields, resolution, state.profiles)
resolveBackends(config.Backends, resolution)
resolveProfiles(config.Profiles, resolution)
resolvePrompts(config.Prompts, resolution)
return nil
},
Validate: validateEffective,
@@ -123,6 +126,9 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
return nil, fmt.Errorf("load configuration: %w", err)
}
state.result = result
if err := state.materializePrompts(); err != nil {
return nil, fmt.Errorf("load configuration: %w", err)
}
return state, nil
}
@@ -153,10 +159,12 @@ func sortedKeys[T any](entries map[string]T) []string {
return keys
}
func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) {
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 resolveBackends(backends map[string]backendConfig, resolution *configkit.Resolution) {

View File

@@ -0,0 +1,312 @@
package configuration
import (
"errors"
"fmt"
"io"
"strings"
"text/template"
"gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
)
var (
errPromptNotFound = errors.New("prompt definition not found")
errPromptAmbiguous = errors.New("prompt version is ambiguous")
)
type promptEntry struct {
definition domain.PromptDefinition
inspection domain.PromptInspection
hash string
session *template.Template
messages []*template.Template
}
func resolvePrompts(prompts map[string]map[string]promptConfig, resolution *configkit.Resolution) {
for _, id := range sortedKeys(prompts) {
for _, version := range sortedKeys(prompts[id]) {
definition := prompts[id][version]
base := configkit.Keys("prompts", id, version)
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))
}
}
inputsChanged := false
for index := range definition.Inputs {
input := &definition.Inputs[index]
inputPath := base.Key("inputs").Index(index)
for _, field := range []struct {
name string
value *string
}{
{"name", &input.Name},
{"content_type", &input.ContentType},
{"description", &input.Description},
} {
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.Record(base.Key("inputs"), configkit.ChangeNormalization, "normalize input metadata")
}
messagesChanged := false
for index := range definition.Messages {
message := &definition.Messages[index]
messagePath := base.Key("messages").Index(index)
if message.Role != nil {
role, err := domain.NormalizeMessageRole(*message.Role)
if err != nil {
resolution.Error(messagePath.Key("role"), err.Error())
} else {
messagesChanged = normalizeString(&message.Role, messagePath.Key("role"), resolution, role) || messagesChanged
}
}
if control := message.CacheControl; control != nil && control.Type != nil {
value := &domain.CacheControl{Type: domain.CacheControlType(*control.Type)}
if control.TTL != nil {
value.TTL = *control.TTL
}
normalized, err := domain.NormalizeCacheControl(value)
if err != nil {
resolution.Error(messagePath.Key("cache_control"), err.Error())
} else {
messagesChanged = normalizeString(&control.Type, messagePath.Key("cache_control").Key("type"), resolution, string(normalized.Type)) || messagesChanged
if control.TTL != nil {
messagesChanged = normalizeString(&control.TTL, messagePath.Key("cache_control").Key("ttl"), resolution, normalized.TTL) || messagesChanged
}
}
}
}
if messagesChanged {
resolution.Record(base.Key("messages"), configkit.ChangeNormalization, "normalize message metadata")
}
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][version] = definition
}
}
}
func (state *loaded) materializePrompts() error {
state.prompts = make(map[string]map[string]*promptEntry, len(state.result.Config.Prompts))
state.ambiguous = make(map[string]bool)
for _, id := range sortedKeys(state.result.Config.Prompts) {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("%s: prompt ID must not be blank", configkit.Keys("prompts", id))
}
versions := state.result.Config.Prompts[id]
if versions == nil {
return fmt.Errorf("%s: prompt collection must be a mapping", configkit.Keys("prompts", id))
}
state.prompts[id] = make(map[string]*promptEntry, len(versions))
state.ambiguous[id] = len(versions) > 1
for _, version := range sortedKeys(versions) {
if strings.TrimSpace(version) == "" {
return fmt.Errorf("%s: prompt version must not be blank", configkit.Keys("prompts", id, version))
}
entry, err := state.materializePrompt(id, version, versions[version])
if err != nil {
return err
}
state.prompts[id][version] = entry
}
}
return nil
}
func (state *loaded) materializePrompt(id, version string, raw promptConfig) (*promptEntry, error) {
base := configkit.Keys("prompts", id, version)
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: version}
if raw.DefaultProfile != nil {
if strings.TrimSpace(*raw.DefaultProfile) == "" {
return nil, fmt.Errorf("%s: default profile must not be blank", base.Key("default_profile"))
}
definition.DefaultProfile = *raw.DefaultProfile
}
if raw.Description != nil {
definition.Description = *raw.Description
}
if raw.SessionID != nil {
definition.SessionID = *raw.SessionID
}
seenInputs := make(map[string]bool, len(raw.Inputs))
definition.Inputs = make([]domain.PromptInput, 0, len(raw.Inputs))
for index, input := range raw.Inputs {
path := base.Key("inputs").Index(index)
if input.Name == "" {
return nil, fmt.Errorf("%s: input name is required", path.Key("name"))
}
if seenInputs[input.Name] {
return nil, fmt.Errorf("%s: input name is duplicated", path.Key("name"))
}
seenInputs[input.Name] = true
definition.Inputs = append(definition.Inputs, domain.PromptInput{
Name: input.Name, Required: input.Required, ContentType: input.ContentType, Description: input.Description,
})
}
entry := &promptEntry{messages: make([]*template.Template, 0, len(raw.Messages))}
definition.Templates = make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
for index, message := range raw.Messages {
path := base.Key("messages").Index(index)
if message.Role == nil {
return nil, fmt.Errorf("%s: role is required", path.Key("role"))
}
if (message.Content != nil) == (message.ContentFile != nil) {
return nil, fmt.Errorf("%s: exactly one of content or content_file is required", path)
}
content := ""
if message.Content != nil {
content = *message.Content
} else {
var err error
content, err = state.readContent(path.Key("content_file"), *message.ContentFile)
if err != nil {
return nil, err
}
}
var control *domain.CacheControl
if message.CacheControl != nil {
if message.CacheControl.Type == nil {
return nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type"))
}
control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)}
if message.CacheControl.TTL != nil {
control.TTL = *message.CacheControl.TTL
}
var err error
control, err = domain.NormalizeCacheControl(control)
if err != nil {
return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err)
}
}
compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content)
if err != nil {
return nil, fmt.Errorf("%s: invalid template syntax: %w", path, err)
}
entry.messages = append(entry.messages, compiled)
definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{
Role: *message.Role, Content: content, CacheControl: control,
})
}
if raw.Output == nil {
return nil, fmt.Errorf("%s: output is required", base.Key("output"))
}
if raw.Output.Format == nil || raw.Output.ValidationMode == nil {
return nil, fmt.Errorf("%s: format and validation_mode are required", base.Key("output"))
}
contract := domain.OutputContract{
Format: domain.OutputFormat(*raw.Output.Format),
ValidationMode: domain.ValidationMode(*raw.Output.ValidationMode),
RepairAttempts: raw.Output.RepairAttempts,
}
if raw.Output.SchemaPath != nil {
contract.SchemaPath = *raw.Output.SchemaPath
}
if err := domain.ValidateOutputContract(contract); err != nil {
return nil, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err)
}
definition.OutputFormat = contract.Format
definition.Validation = contract
if raw.SessionID != nil {
var err error
entry.session, err = prompt.CompileTemplate("session_id", definition.SessionID)
if err != nil {
return nil, fmt.Errorf("%s: invalid template syntax: %w", base.Key("session_id"), err)
}
}
hash, err := promptdef.HashDefinition(&definition)
if err != nil {
return nil, fmt.Errorf("%s: hash prompt definition: %w", base, err)
}
entry.definition = definition
entry.hash = hash
entry.inspection = domain.PromptInspection{
PromptID: id, PromptVersion: version, PromptHash: hash,
DefaultProfileID: definition.DefaultProfile, Inputs: append([]domain.PromptInput(nil), definition.Inputs...),
OutputContract: contract,
}
return entry, nil
}
func (state *loaded) readContent(fieldPath configkit.Path, name string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("%s: content_file path must not be blank", fieldPath)
}
field, found := state.result.Metadata().Lookup(fieldPath)
if !found || len(field.Origin.Locations) != 1 {
return "", fmt.Errorf("%s: content_file needs one contributing source with a filesystem context", fieldPath)
}
sourceID := field.Origin.Locations[0].SourceID
context, found := state.result.SourceContext(sourceID)
if !found || context.FS == nil {
return "", fmt.Errorf("%s: content_file source %s has no filesystem context", fieldPath, sourceID)
}
resolved, _, err := filecatalog.ResolveFSPath(".", context.BaseDir, name)
if err != nil {
return "", fmt.Errorf("%s: content_file path is outside its source root or invalid: %w", fieldPath, err)
}
file, err := context.FS.Open(resolved)
if err != nil {
return "", fmt.Errorf("%s: open content_file from source %s: %w", fieldPath, sourceID, err)
}
content, readErr := io.ReadAll(file)
closeErr := file.Close()
if readErr != nil {
return "", fmt.Errorf("%s: read content_file from source %s: %w", fieldPath, sourceID, readErr)
}
if closeErr != nil {
return "", fmt.Errorf("%s: close content_file from source %s: %w", fieldPath, sourceID, closeErr)
}
return string(content), nil
}
func (state *loaded) prompt(id, version string) (*domain.PromptDefinition, domain.PromptInspection, error) {
if state == nil || strings.TrimSpace(id) == "" || (version != "" && strings.TrimSpace(version) == "") {
return nil, domain.PromptInspection{}, errPromptNotFound
}
versions := state.prompts[id]
if version == "" {
if state.ambiguous[id] {
return nil, domain.PromptInspection{}, errPromptAmbiguous
}
for selected := range versions {
version = selected
}
}
entry := versions[version]
if entry == nil {
return nil, domain.PromptInspection{}, errPromptNotFound
}
definition := entry.definition
definition.Inputs = append([]domain.PromptInput(nil), definition.Inputs...)
definition.Templates = append([]domain.PromptMessageTemplate(nil), definition.Templates...)
for index := range definition.Templates {
if control := definition.Templates[index].CacheControl; control != nil {
copy := *control
definition.Templates[index].CacheControl = &copy
}
}
inspection := entry.inspection
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
return &definition, inspection, nil
}

View File

@@ -0,0 +1,189 @@
package configuration
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit"
)
func TestLoadCapturesInheritedImportedContent(t *testing.T) {
files := fstest.MapFS{
"app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\nprompts:\n child:\n extends: base\n")},
"catalog/base.yaml": {Data: []byte("prompts:\n base:\n v1:\n messages:\n - role: user\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")},
"catalog/body.md": {Data: []byte("Captured {{.name}}")},
}
namespace := configkit.NewFilesystem("embedded", files)
state, err := load(configkit.Input{
Source: namespace.File("app/main.yaml"), Imports: true,
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: "."},
}, nil)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("%v: %+v", err, loadError.Diagnostics())
}
t.Fatal(err)
}
definition, inspection, err := state.prompt("child", "")
if err != nil {
t.Fatal(err)
}
if definition.Templates[0].Content != "Captured {{.name}}" || inspection.PromptHash == "" {
t.Fatalf("inherited file content was not captured: %+v, %+v", definition, inspection)
}
files["catalog/body.md"].Data = []byte("Changed")
again, againInspection, err := state.prompt("child", "v1")
if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash {
t.Fatalf("captured definition changed after source mutation: %+v, %+v, %v", again, againInspection, err)
}
definition.Templates[0].Content = "caller mutation"
third, _, err := state.prompt("child", "v1")
if err != nil || third.Templates[0].Content != "Captured {{.name}}" {
t.Fatalf("caller mutation escaped into index: %+v, %v", third, err)
}
}
func TestLoadUsesExplicitMappingContextAndClosesContent(t *testing.T) {
filesystem := &countingFS{FS: fstest.MapFS{
"assets/body.md": {Data: []byte("From mapping")},
}}
namespace := configkit.NewFilesystem("mapping-context", filesystem)
source := configkit.Mapping("application", promptWithFile("body.md"))
state, err := load(configkit.Input{
Source: source,
Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: ".", BaseDir: "assets"},
}, nil)
if err != nil {
t.Fatal(err)
}
definition, _, err := state.prompt("example", "v1")
if err != nil || definition.Templates[0].Content != "From mapping" || filesystem.contentOpened != 1 || filesystem.contentClosed != 1 {
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/v1/messages/0/content_file") {
t.Fatalf("expected semantic path for contextless resource, got %v", err)
}
}
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 v1:\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 {
t.Fatal(err)
}
state, err := load(configkit.Input{Source: configkit.File(definitionPath), Filesystem: &configkit.FilesystemContext{Root: root}}, nil)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(contentPath, []byte("Modified"), 0o600); err != nil {
t.Fatal(err)
}
definition, _, err := state.prompt("example", "v1")
if err != nil || definition.Templates[0].Content != "Native body" {
t.Fatalf("native content was not captured: %+v, %v", definition, err)
}
}
func TestLoadRejectsMalformedStaticSyntaxAndAmbiguousSelection(t *testing.T) {
_, err := load(mapping("bad-template", map[string]any{"prompts": map[string]any{"example": map[string]any{
"v1": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "{{if}}"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
}}}), nil)
if err == nil || !strings.Contains(err.Error(), "invalid template syntax") {
t.Fatalf("expected construction-time syntax error, got %v", err)
}
state, err := load(mapping("versions", map[string]any{"prompts": map[string]any{"example": map[string]any{
"v1": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "one"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
"v2": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "two"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"}},
}}}), nil)
if err != nil {
t.Fatal(err)
}
if _, _, err := state.prompt("example", ""); !errors.Is(err, errPromptAmbiguous) {
t.Fatalf("expected ambiguous version selection, got %v", err)
}
}
func TestLoadAcceptsEmptyInlineContentAndRejectsInvalidPromptFields(t *testing.T) {
state, err := load(mapping("empty-content", map[string]any{"prompts": map[string]any{" exact.id ": map[string]any{"v1": map[string]any{
"inputs": []any{map[string]any{"name": " note ", "required": true}},
"messages": []any{map[string]any{"role": " USER ", "content": "", "content_file": nil,
"cache_control": map[string]any{"type": " ephemeral ", "ttl": "1h "}}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}}), nil)
if err != nil {
var loadError *configkit.LoadError
if errors.As(err, &loadError) {
t.Fatalf("%v: %+v", err, loadError.Diagnostics())
}
t.Fatal(err)
}
definition, _, err := state.prompt(" exact.id ", "v1")
if err != nil || definition.ID != " exact.id " || definition.Templates[0].Content != "" || definition.Templates[0].Role != "user" ||
definition.Inputs[0].Name != "note" || definition.Templates[0].CacheControl.TTL != "1h" {
t.Fatalf("explicit empty content or literal ID was changed: %+v, %v", definition, err)
}
if _, _, err := state.prompt("exact.id", "v1"); !errors.Is(err, errPromptNotFound) {
t.Fatalf("trimmed prompt identity unexpectedly matched: %v", err)
}
_, err = load(mapping("obsolete-field", map[string]any{"prompts": map[string]any{"example": map[string]any{"v1": map[string]any{
"id": "example", "messages": []any{map[string]any{"role": "user", "content": "ok"}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}}), nil)
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("obsolete identity field should fail strict decoding: %v", err)
}
_, err = load(mapping("null-prompt", map[string]any{"prompts": map[string]any{"example": nil}}), nil)
if err == nil {
t.Fatal("null prompt collection was accepted")
}
}
func promptWithFile(name string) map[string]any {
return map[string]any{"prompts": map[string]any{"example": map[string]any{"v1": map[string]any{
"messages": []any{map[string]any{"role": "user", "content_file": name}},
"output": map[string]any{"format": "text", "validation_mode": "basic"},
}}}}
}
type countingFS struct {
fs.FS
contentOpened int
contentClosed int
}
func (filesystem *countingFS) Open(name string) (fs.File, error) {
file, err := filesystem.FS.Open(name)
if err != nil {
return nil, err
}
if name == "assets/body.md" {
filesystem.contentOpened++
return &countingFile{File: file, owner: filesystem}, nil
}
return file, nil
}
type countingFile struct {
fs.File
owner *countingFS
}
func (file *countingFile) Close() error {
file.owner.contentClosed++
return file.File.Close()
}

View File

@@ -76,7 +76,7 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini
if err := ctx.Err(); err != nil {
return nil, err
}
tmpl, parseErr := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Option("missingkey=error").Parse(tmplMsg.Content)
tmpl, parseErr := parseTemplate(fmt.Sprintf("msg_%d", i), tmplMsg.Content, funcs)
if err := ctx.Err(); err != nil {
return nil, err
}
@@ -167,7 +167,7 @@ func renderSessionID(ctx context.Context, raw string, funcs template.FuncMap, va
if err := ctx.Err(); err != nil {
return "", err
}
tmpl, parseErr := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw)
tmpl, parseErr := parseTemplate("session_id", raw, funcs)
if err := ctx.Err(); err != nil {
return "", err
}

View File

@@ -0,0 +1,15 @@
package prompt
import "text/template"
// CompileTemplate checks static syntax with the same grammar and function
// names as rendering, without evaluating request inputs or variables.
func CompileTemplate(name, raw string) (*template.Template, error) {
return parseTemplate(name, raw, template.FuncMap{
"input": func(string) (string, error) { return "", nil },
})
}
func parseTemplate(name, raw string, funcs template.FuncMap) (*template.Template, error) {
return template.New(name).Funcs(funcs).Option("missingkey=error").Parse(raw)
}

View File

@@ -0,0 +1,19 @@
package promptdef
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
)
// HashDefinition hashes an execution-ready prompt, including captured content.
func HashDefinition(definition *domain.PromptDefinition) (string, error) {
bytes, err := json.Marshal(definition)
if err != nil {
return "", err
}
sum := sha256.Sum256(bytes)
return hex.EncodeToString(sum[:]), nil
}

View File

@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
@@ -809,10 +808,5 @@ func buildOutputArtifact(content string, format domain.OutputFormat) domain.Arti
}
func hashPromptDefinition(def *domain.PromptDefinition) (string, error) {
b, err := json.Marshal(def)
if err != nil {
return "", err
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:]), nil
return promptdef.HashDefinition(def)
}