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) |
|
| `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/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/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/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/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) |
|
| `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
|
effective field presence (including explicit null), normalizes backend and
|
||||||
profile fields through ConfigKit resolution, and validates final backend and
|
profile fields through ConfigKit resolution, and validates final backend and
|
||||||
profile domain values. A lower-layer domain value replaced before the handoff
|
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
|
This loader is not yet wired into engine assembly. The public engine continues
|
||||||
to use the source and repository behavior described below; prompt resource
|
to use the source and repository behavior described below. The public
|
||||||
capture and full prompt validation are subsequent implementation work.
|
`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
|
## 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
|
## Stage 2 — Add Source Discovery And Prompt Resource Capture
|
||||||
|
|
||||||
|
**Completed:** 2026-09-30.
|
||||||
|
|
||||||
Scope: source selection helpers and construction-time prompt materialization.
|
Scope: source selection helpers and construction-time prompt materialization.
|
||||||
|
|
||||||
1. Implement private directory/FS discovery behind the chosen helper signatures;
|
1. Implement private directory/FS discovery behind the chosen helper signatures;
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ type loaded struct {
|
|||||||
result *configkit.Result[document]
|
result *configkit.Result[document]
|
||||||
backends map[string]map[string]fieldState
|
backends map[string]map[string]fieldState
|
||||||
profiles 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_]*$`)
|
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)
|
captureFields(config.Profiles, "profiles", profileFields, resolution, state.profiles)
|
||||||
resolveBackends(config.Backends, resolution)
|
resolveBackends(config.Backends, resolution)
|
||||||
resolveProfiles(config.Profiles, resolution)
|
resolveProfiles(config.Profiles, resolution)
|
||||||
|
resolvePrompts(config.Prompts, resolution)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Validate: validateEffective,
|
Validate: validateEffective,
|
||||||
@@ -123,6 +126,9 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error)
|
|||||||
return nil, fmt.Errorf("load configuration: %w", err)
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
}
|
}
|
||||||
state.result = result
|
state.result = result
|
||||||
|
if err := state.materializePrompts(); err != nil {
|
||||||
|
return nil, fmt.Errorf("load configuration: %w", err)
|
||||||
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +159,12 @@ func sortedKeys[T any](entries map[string]T) []string {
|
|||||||
return keys
|
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 {
|
if value != nil && *value != nil && **value != normalized {
|
||||||
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
|
configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value")
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveBackends(backends map[string]backendConfig, resolution *configkit.Resolution) {
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ func renderSessionID(ctx context.Context, raw string, funcs template.FuncMap, va
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
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 {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
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"
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash"
|
"hash"
|
||||||
@@ -809,10 +808,5 @@ func buildOutputArtifact(content string, format domain.OutputFormat) domain.Arti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hashPromptDefinition(def *domain.PromptDefinition) (string, error) {
|
func hashPromptDefinition(def *domain.PromptDefinition) (string, error) {
|
||||||
b, err := json.Marshal(def)
|
return promptdef.HashDefinition(def)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(b)
|
|
||||||
return hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user