From b158a8ec2d3201597de908cd7f7c5a175abd6415 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 30 Sep 2026 02:19:52 +0000 Subject: [PATCH] Capture keyed prompts and source-backed content at load time --- configuration_sources.go | 78 +++++++ configuration_sources_test.go | 83 +++++++ docs/internal/overview.md | 2 +- docs/internal/sources.md | 11 +- docs/roadmap/implementation.md | 2 + internal/configuration/load.go | 16 +- internal/configuration/prompts.go | 312 +++++++++++++++++++++++++ internal/configuration/prompts_test.go | 189 +++++++++++++++ internal/prompt/go_renderer.go | 4 +- internal/prompt/template.go | 15 ++ internal/promptdef/hash.go | 19 ++ internal/usecase/runner.go | 8 +- 12 files changed, 722 insertions(+), 17 deletions(-) create mode 100644 configuration_sources.go create mode 100644 configuration_sources_test.go create mode 100644 internal/configuration/prompts.go create mode 100644 internal/configuration/prompts_test.go create mode 100644 internal/prompt/template.go create mode 100644 internal/promptdef/hash.go diff --git a/configuration_sources.go b/configuration_sources.go new file mode 100644 index 0000000..dbf37e3 --- /dev/null +++ b/configuration_sources.go @@ -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 +} diff --git a/configuration_sources_test.go b/configuration_sources_test.go new file mode 100644 index 0000000..824e6e3 --- /dev/null +++ b/configuration_sources_test.go @@ -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) + } +} diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 1609b06..2459da5 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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) | diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 1868e38..65f9e65 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index cfc1dd9..0e036ad 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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; diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 4ee8ace..9e15aa4 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -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) { diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go new file mode 100644 index 0000000..9304f89 --- /dev/null +++ b/internal/configuration/prompts.go @@ -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 +} diff --git a/internal/configuration/prompts_test.go b/internal/configuration/prompts_test.go new file mode 100644 index 0000000..d8cbfc8 --- /dev/null +++ b/internal/configuration/prompts_test.go @@ -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() +} diff --git a/internal/prompt/go_renderer.go b/internal/prompt/go_renderer.go index a49861c..dbef141 100644 --- a/internal/prompt/go_renderer.go +++ b/internal/prompt/go_renderer.go @@ -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 } diff --git a/internal/prompt/template.go b/internal/prompt/template.go new file mode 100644 index 0000000..9694bed --- /dev/null +++ b/internal/prompt/template.go @@ -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) +} diff --git a/internal/promptdef/hash.go b/internal/promptdef/hash.go new file mode 100644 index 0000000..ef0fe07 --- /dev/null +++ b/internal/promptdef/hash.go @@ -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 +} diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 0aef169..38b6bcd 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -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) }