Add prompt reference template functions

This commit is contained in:
2026-07-05 14:37:39 +00:00
parent a57c6397e3
commit 9e89b88efc
4 changed files with 415 additions and 14 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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)},
}
}