Capture keyed prompts and source-backed content at load time
This commit is contained in:
78
configuration_sources.go
Normal file
78
configuration_sources.go
Normal 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
|
||||
}
|
||||
83
configuration_sources_test.go
Normal file
83
configuration_sources_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
312
internal/configuration/prompts.go
Normal file
312
internal/configuration/prompts.go
Normal 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 = ©
|
||||
}
|
||||
}
|
||||
inspection := entry.inspection
|
||||
inspection.Inputs = append([]domain.PromptInput(nil), inspection.Inputs...)
|
||||
return &definition, inspection, nil
|
||||
}
|
||||
189
internal/configuration/prompts_test.go
Normal file
189
internal/configuration/prompts_test.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
15
internal/prompt/template.go
Normal file
15
internal/prompt/template.go
Normal 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)
|
||||
}
|
||||
19
internal/promptdef/hash.go
Normal file
19
internal/promptdef/hash.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user