From 9e89b88efc1a75574b4a0a3df3540ed1a84987c0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 5 Jul 2026 14:37:39 +0000 Subject: [PATCH] Add prompt reference template functions --- docs/internal/pipeline.md | 6 + internal/framework/prompt/registry.go | 154 ++++++++++++++++++-- internal/framework/prompt/render.go | 92 +++++++++++- internal/framework/prompt/render_test.go | 177 +++++++++++++++++++++++ 4 files changed, 415 insertions(+), 14 deletions(-) diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index 0cc4f8c..a0c629e 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -44,6 +44,12 @@ CLI bindings resolve relative to the current working directory, and materialized reference content is passed to extractors through `ExtractionRequest`. Reference content is omitted from diagnostics and manifests. +Prompt bundles can declare reference slots and use `reference` and +`hasreference` template functions. Bundle loading validates string-literal slot +names against the declaration. Rendering receives a lane reference set from the +caller; unbound optional slots render as empty strings, and `hasreference` +returns true only when at least one bound item has content. + ## Registries And Module Specs `pipeline.Registries` holds concrete constructors for execution. A diff --git a/internal/framework/prompt/registry.go b/internal/framework/prompt/registry.go index 4644ab4..bdaaabe 100644 --- a/internal/framework/prompt/registry.go +++ b/internal/framework/prompt/registry.go @@ -7,9 +7,13 @@ import ( "fmt" "io/fs" "path" + "reflect" "sort" "strings" "text/template" + "text/template/parse" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) //go:embed assets/** @@ -43,18 +47,20 @@ func (m Metadata) DiagnosticsMap() map[string]any { // Definition identifies a caller-owned system/user prompt bundle. type Definition struct { - PromptID string - Version string - EmbeddedPath string - SystemPath string - UserPath string + PromptID string + Version string + EmbeddedPath string + SystemPath string + UserPath string + ReferenceSlots []contracts.ReferenceSlot } // Bundle is a compiled system/user prompt pair. type Bundle struct { - systemTmpl *template.Template - userTmpl *template.Template - metadata Metadata + systemTmpl *template.Template + userTmpl *template.Template + metadata Metadata + referenceSlots map[string]contracts.ReferenceSlot } // Metadata returns metadata for the compiled prompt bundle. @@ -168,7 +174,9 @@ func LoadBundle(fsys fs.FS, def Definition) (*Bundle, error) { } funcs := template.FuncMap{ - "hardening": func() string { return sharedHardening }, + "hardening": func() string { return sharedHardening }, + "reference": func(string) (string, error) { return "", nil }, + "hasreference": func(string) (bool, error) { return false, nil }, } systemTmpl, err := template.New(path.Base(systemPath)).Option("missingkey=error").Funcs(funcs).Parse(systemSource) if err != nil { @@ -178,6 +186,13 @@ func LoadBundle(fsys fs.FS, def Definition) (*Bundle, error) { if err != nil { return nil, fmt.Errorf("parse embedded user prompt %q: %w", userPath, err) } + referenceSlots := referenceSlotMap(def.ReferenceSlots) + if err := validateTemplateReferenceSlots(systemTmpl, referenceSlots); err != nil { + return nil, fmt.Errorf("validate embedded system prompt %q: %w", systemPath, err) + } + if err := validateTemplateReferenceSlots(userTmpl, referenceSlots); err != nil { + return nil, fmt.Errorf("validate embedded user prompt %q: %w", userPath, err) + } hashInput := systemSource + "\n\n" + userSource hash := sha256.Sum256([]byte(hashInput)) @@ -190,12 +205,127 @@ func LoadBundle(fsys fs.FS, def Definition) (*Bundle, error) { } return &Bundle{ - systemTmpl: systemTmpl, - userTmpl: userTmpl, - metadata: metadata, + systemTmpl: systemTmpl, + userTmpl: userTmpl, + metadata: metadata, + referenceSlots: referenceSlots, }, nil } +func referenceSlotMap(slots []contracts.ReferenceSlot) map[string]contracts.ReferenceSlot { + if len(slots) == 0 { + return nil + } + out := make(map[string]contracts.ReferenceSlot, len(slots)) + for _, slot := range slots { + name := strings.TrimSpace(slot.Name) + if name == "" { + continue + } + slot.Name = name + slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...) + out[name] = slot + } + return out +} + +func validateTemplateReferenceSlots(tmpl *template.Template, declared map[string]contracts.ReferenceSlot) error { + if tmpl == nil || tmpl.Tree == nil || tmpl.Tree.Root == nil { + return nil + } + return validateReferenceNodes(tmpl.Tree.Root, declared) +} + +func validateReferenceNodes(node parse.Node, declared map[string]contracts.ReferenceSlot) error { + if node == nil || reflect.ValueOf(node).IsNil() { + return nil + } + switch typed := node.(type) { + case *parse.ListNode: + for _, child := range typed.Nodes { + if err := validateReferenceNodes(child, declared); err != nil { + return err + } + } + case *parse.ActionNode: + return validateReferencePipeline(typed.Pipe, declared) + case *parse.IfNode: + if err := validateReferencePipeline(typed.Pipe, declared); err != nil { + return err + } + if err := validateReferenceNodes(typed.List, declared); err != nil { + return err + } + return validateReferenceNodes(typed.ElseList, declared) + case *parse.RangeNode: + if err := validateReferencePipeline(typed.Pipe, declared); err != nil { + return err + } + if err := validateReferenceNodes(typed.List, declared); err != nil { + return err + } + return validateReferenceNodes(typed.ElseList, declared) + case *parse.WithNode: + if err := validateReferencePipeline(typed.Pipe, declared); err != nil { + return err + } + if err := validateReferenceNodes(typed.List, declared); err != nil { + return err + } + return validateReferenceNodes(typed.ElseList, declared) + case *parse.TemplateNode: + return nil + } + return nil +} + +func validateReferencePipeline(pipe *parse.PipeNode, declared map[string]contracts.ReferenceSlot) error { + if pipe == nil { + return nil + } + for _, cmd := range pipe.Cmds { + if err := validateReferenceCommand(cmd, declared); err != nil { + return err + } + } + return nil +} + +func validateReferenceCommand(cmd *parse.CommandNode, declared map[string]contracts.ReferenceSlot) error { + if cmd == nil || len(cmd.Args) == 0 { + return nil + } + for _, arg := range cmd.Args[1:] { + if nested, ok := arg.(*parse.PipeNode); ok { + if err := validateReferencePipeline(nested, declared); err != nil { + return err + } + } + } + identifier, ok := cmd.Args[0].(*parse.IdentifierNode) + if !ok { + return nil + } + if identifier.Ident != "reference" && identifier.Ident != "hasreference" { + return nil + } + if len(cmd.Args) != 2 { + return fmt.Errorf("%s requires one string slot name", identifier.Ident) + } + slotArg, ok := cmd.Args[1].(*parse.StringNode) + if !ok { + return fmt.Errorf("%s requires a string literal slot name", identifier.Ident) + } + slotName := strings.TrimSpace(slotArg.Text) + if slotName == "" { + return fmt.Errorf("%s slot name must not be empty", identifier.Ident) + } + if _, ok := declared[slotName]; !ok { + return fmt.Errorf("%s slot %q is not declared", identifier.Ident, slotName) + } + return nil +} + func readPromptAsset(fsys fs.FS, assetPath string) (string, error) { if strings.TrimSpace(assetPath) == "" { return "", fmt.Errorf("prompt asset path must not be empty") diff --git a/internal/framework/prompt/render.go b/internal/framework/prompt/render.go index 67d1343..820620a 100644 --- a/internal/framework/prompt/render.go +++ b/internal/framework/prompt/render.go @@ -4,6 +4,9 @@ import ( "bytes" "fmt" "strings" + "text/template" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) // RenderUserSystem renders the system and user prompt pair for promptID. @@ -16,20 +19,105 @@ func RenderUserSystem(promptID string, data any) (system string, user string, me return compiled.RenderUserSystem(data) } +// RenderUserSystemWithReferences renders the system and user prompt pair for promptID with reference template functions. +func RenderUserSystemWithReferences(promptID string, data any, references contracts.ReferenceSet) (system string, user string, metadata Metadata, err error) { + trimmedID := strings.TrimSpace(promptID) + compiled, ok := promptRegistry[trimmedID] + if !ok { + return "", "", Metadata{}, fmt.Errorf("unknown prompt id %q", promptID) + } + return compiled.RenderUserSystemWithReferences(data, references) +} + // RenderUserSystem renders the bundle's system and user prompts. func (b *Bundle) RenderUserSystem(data any) (system string, user string, metadata Metadata, err error) { + return b.RenderUserSystemWithReferences(data, contracts.ReferenceSet{}) +} + +// RenderUserSystemWithReferences renders the bundle's system and user prompts with reference template functions. +func (b *Bundle) RenderUserSystemWithReferences(data any, references contracts.ReferenceSet) (system string, user string, metadata Metadata, err error) { if b == nil { return "", "", Metadata{}, fmt.Errorf("prompt bundle must not be nil") } + systemTmpl, userTmpl, err := b.renderTemplates(references) + if err != nil { + return "", "", Metadata{}, err + } var systemBuf bytes.Buffer - if err := b.systemTmpl.Execute(&systemBuf, data); err != nil { + if err := systemTmpl.Execute(&systemBuf, data); err != nil { return "", "", Metadata{}, fmt.Errorf("render system prompt %q: %w", b.metadata.PromptID, err) } var userBuf bytes.Buffer - if err := b.userTmpl.Execute(&userBuf, data); err != nil { + if err := userTmpl.Execute(&userBuf, data); err != nil { return "", "", Metadata{}, fmt.Errorf("render user prompt %q: %w", b.metadata.PromptID, err) } return strings.TrimSpace(systemBuf.String()), strings.TrimSpace(userBuf.String()), b.metadata, nil } + +func (b *Bundle) renderTemplates(references contracts.ReferenceSet) (*template.Template, *template.Template, error) { + funcs := b.referenceFuncs(references) + systemTmpl, err := b.systemTmpl.Clone() + if err != nil { + return nil, nil, fmt.Errorf("clone system prompt %q: %w", b.metadata.PromptID, err) + } + userTmpl, err := b.userTmpl.Clone() + if err != nil { + return nil, nil, fmt.Errorf("clone user prompt %q: %w", b.metadata.PromptID, err) + } + systemTmpl.Funcs(funcs) + userTmpl.Funcs(funcs) + return systemTmpl, userTmpl, nil +} + +func (b *Bundle) referenceFuncs(references contracts.ReferenceSet) template.FuncMap { + return template.FuncMap{ + "hardening": func() string { return sharedHardening }, + "hasreference": func(slotName string) (bool, error) { + items, _, err := b.referenceItems(slotName, references) + if err != nil { + return false, err + } + for _, item := range items { + if len(item.Content) > 0 { + return true, nil + } + } + return false, nil + }, + "reference": func(slotName string) (string, error) { + items, slot, err := b.referenceItems(slotName, references) + if err != nil { + return "", err + } + if len(items) == 0 { + return "", nil + } + if len(items) > 1 && !slot.Multiple { + return "", fmt.Errorf("reference slot %q has %d bound items but does not allow multiple", slot.Name, len(items)) + } + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, string(item.Content)) + } + return strings.Join(parts, "\n"), nil + }, + } +} + +func (b *Bundle) referenceItems(slotName string, references contracts.ReferenceSet) ([]contracts.ReferenceItem, contracts.ReferenceSlot, error) { + slotName = strings.TrimSpace(slotName) + slot, ok := b.referenceSlots[slotName] + if !ok { + return nil, contracts.ReferenceSlot{}, fmt.Errorf("reference slot %q is not declared", slotName) + } + if len(references.Slots) == 0 { + return nil, slot, nil + } + resolved, ok := references.Slots[slotName] + if !ok { + return nil, slot, nil + } + return append([]contracts.ReferenceItem(nil), resolved.Items...), slot, nil +} diff --git a/internal/framework/prompt/render_test.go b/internal/framework/prompt/render_test.go index cda4deb..a29d6d6 100644 --- a/internal/framework/prompt/render_test.go +++ b/internal/framework/prompt/render_test.go @@ -1,8 +1,13 @@ package prompt import ( + "crypto/sha256" + "encoding/hex" "strings" "testing" + "testing/fstest" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) func TestRenderUserSystemReturnsTextAndMetadata(t *testing.T) { @@ -64,3 +69,175 @@ func TestRenderUserSystemIncludesHardeningText(t *testing.T) { t.Fatalf("expected rendered system prompt to include hardening text: %q", system) } } + +func TestRenderUserSystemWithReferencesRendersDeclaredSlots(t *testing.T) { + bundle := loadReferenceBundle(t, + []contracts.ReferenceSlot{{Name: "roster"}, {Name: "glossary"}}, + `System has roster={{ hasreference "roster" }} has glossary={{ hasreference "glossary" }}`, + `Roster={{ reference "roster" }} Glossary={{ reference "glossary" }}`, + ) + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{ + {SlotName: "roster", Content: []byte("Aria")}, + }, + }, + }} + + system, user, _, err := bundle.RenderUserSystemWithReferences(map[string]any{}, references) + if err != nil { + t.Fatalf("RenderUserSystemWithReferences: %v", err) + } + if !strings.Contains(system, "has roster=true") || !strings.Contains(system, "has glossary=false") { + t.Fatalf("system = %q, want reference presence flags", system) + } + if !strings.Contains(user, "Roster=Aria") || !strings.Contains(user, "Glossary=") { + t.Fatalf("user = %q, want rendered and empty optional references", user) + } +} + +func TestRenderUserSystemReferenceHasReferenceRequiresContent(t *testing.T) { + bundle := loadReferenceBundle(t, + []contracts.ReferenceSlot{{Name: "roster"}}, + `System`, + `{{ hasreference "roster" }} {{ reference "roster" }}`, + ) + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{ + {SlotName: "roster", Content: nil}, + }, + }, + }} + + _, user, _, err := bundle.RenderUserSystemWithReferences(map[string]any{}, references) + if err != nil { + t.Fatalf("RenderUserSystemWithReferences: %v", err) + } + if user != "false" { + t.Fatalf("user = %q, want false with empty reference content", user) + } +} + +func TestLoadBundleRejectsUndeclaredReferenceSlots(t *testing.T) { + _, err := LoadBundle(referenceBundleFS(`System`, `{{ reference "roster" }}`), referenceBundleDefinition(nil)) + if err == nil || !strings.Contains(err.Error(), "roster") || !strings.Contains(err.Error(), "not declared") { + t.Fatalf("LoadBundle() error = %v, want undeclared reference slot error", err) + } +} + +func TestLoadBundleRejectsDynamicReferenceSlotNames(t *testing.T) { + _, err := LoadBundle(referenceBundleFS(`System`, `{{ reference .SlotName }}`), referenceBundleDefinition([]contracts.ReferenceSlot{{Name: "roster"}})) + if err == nil || !strings.Contains(err.Error(), "string literal") { + t.Fatalf("LoadBundle() error = %v, want string literal error", err) + } +} + +func TestLoadBundleRejectsNestedUndeclaredReferenceSlots(t *testing.T) { + _, err := LoadBundle(referenceBundleFS(`System`, `{{ printf "%s" (reference "roster") }}`), referenceBundleDefinition(nil)) + if err == nil || !strings.Contains(err.Error(), "roster") || !strings.Contains(err.Error(), "not declared") { + t.Fatalf("LoadBundle() error = %v, want nested undeclared reference slot error", err) + } +} + +func TestRenderUserSystemRejectsMultipleReferenceItemsUnlessDeclared(t *testing.T) { + bundle := loadReferenceBundle(t, + []contracts.ReferenceSlot{{Name: "roster"}}, + `System`, + `{{ reference "roster" }}`, + ) + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{ + {SlotName: "roster", Content: []byte("Aria")}, + {SlotName: "roster", Content: []byte("Bryn")}, + }, + }, + }} + + _, _, _, err := bundle.RenderUserSystemWithReferences(map[string]any{}, references) + if err == nil || !strings.Contains(err.Error(), "does not allow multiple") { + t.Fatalf("RenderUserSystemWithReferences() error = %v, want multiple item error", err) + } +} + +func TestRenderUserSystemRendersMultipleReferenceItemsDeterministicallyWhenDeclared(t *testing.T) { + bundle := loadReferenceBundle(t, + []contracts.ReferenceSlot{{Name: "roster", Multiple: true}}, + `System`, + `{{ reference "roster" }}`, + ) + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster", Multiple: true}, + Items: []contracts.ReferenceItem{ + {SlotName: "roster", Content: []byte("Aria")}, + {SlotName: "roster", Content: []byte("Bryn")}, + }, + }, + }} + + _, first, _, err := bundle.RenderUserSystemWithReferences(map[string]any{}, references) + if err != nil { + t.Fatalf("RenderUserSystemWithReferences(first): %v", err) + } + _, second, _, err := bundle.RenderUserSystemWithReferences(map[string]any{}, references) + if err != nil { + t.Fatalf("RenderUserSystemWithReferences(second): %v", err) + } + if first != "Aria\nBryn" || first != second { + t.Fatalf("rendered references = %q/%q, want deterministic item order", first, second) + } +} + +func TestPromptMetadataHashIgnoresRenderedReferenceContent(t *testing.T) { + systemSource := `System` + userSource := `{{ reference "roster" }}` + bundle := loadReferenceBundle(t, []contracts.ReferenceSlot{{Name: "roster"}}, systemSource, userSource) + references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + "roster": { + Slot: contracts.ReferenceSlot{Name: "roster"}, + Items: []contracts.ReferenceItem{{SlotName: "roster", Content: []byte("Aria")}}, + }, + }} + + _, _, metadata, err := bundle.RenderUserSystemWithReferences(map[string]any{}, references) + if err != nil { + t.Fatalf("RenderUserSystemWithReferences: %v", err) + } + hash := sha256.Sum256([]byte(systemSource + "\n\n" + userSource)) + want := "sha256:" + hex.EncodeToString(hash[:]) + if metadata.SHA256 != want { + t.Fatalf("metadata.SHA256 = %q, want template source hash %q", metadata.SHA256, want) + } +} + +func loadReferenceBundle(t *testing.T, slots []contracts.ReferenceSlot, systemSource string, userSource string) *Bundle { + t.Helper() + bundle, err := LoadBundle(referenceBundleFS(systemSource, userSource), referenceBundleDefinition(slots)) + if err != nil { + t.Fatalf("LoadBundle() error = %v, want nil", err) + } + return bundle +} + +func referenceBundleDefinition(slots []contracts.ReferenceSlot) Definition { + return Definition{ + PromptID: "test.references", + Version: VersionV1, + EmbeddedPath: "assets/test/references", + SystemPath: "assets/test/references/system.md", + UserPath: "assets/test/references/user.md", + ReferenceSlots: slots, + } +} + +func referenceBundleFS(systemSource string, userSource string) fstest.MapFS { + return fstest.MapFS{ + "assets/test/references/system.md": {Data: []byte(systemSource)}, + "assets/test/references/user.md": {Data: []byte(userSource)}, + } +}