Render captured prompt templates without reloading sources
This commit is contained in:
@@ -497,6 +497,8 @@ guidance in this stage so removed options are not presented as usable.
|
|||||||
|
|
||||||
## Stage 8 — Complete Frozen Rendering And Lifecycle Integration
|
## Stage 8 — Complete Frozen Rendering And Lifecycle Integration
|
||||||
|
|
||||||
|
**Completed:** 2026-09-30.
|
||||||
|
|
||||||
Scope: renderer/runtime consumption of the snapshot, ownership, and lifecycle
|
Scope: renderer/runtime consumption of the snapshot, ownership, and lifecycle
|
||||||
regressions. Stage 2 already owns acquisition and static syntax validation.
|
regressions. Stage 2 already owns acquisition and static syntax validation.
|
||||||
|
|
||||||
|
|||||||
177
frozen_rendering_contract_test.go
Normal file
177
frozen_rendering_contract_test.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
const capturedPromptDocument = `
|
||||||
|
prompts:
|
||||||
|
captured:
|
||||||
|
"1":
|
||||||
|
default_profile: offline
|
||||||
|
session_id: '{{.session}}'
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: body.tmpl
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
`
|
||||||
|
|
||||||
|
func capturedPromptEngine(t *testing.T, source fs.FS) *promptkit.Engine {
|
||||||
|
t.Helper()
|
||||||
|
inputs, err := promptkit.FilesystemInputs("application prompts", source, "config")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("select prompt inputs: %v", err)
|
||||||
|
}
|
||||||
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: inputs},
|
||||||
|
{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("offline profile", map[string]promptkit.ProfileConfig{
|
||||||
|
"offline": {Endpoint: &endpoint, Model: &model},
|
||||||
|
})}}},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngineCapturesPromptContentUntilReconstruction(t *testing.T) {
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
||||||
|
"config/body.tmpl": &fstest.MapFile{Data: []byte("before {{.name}}")},
|
||||||
|
}
|
||||||
|
first := capturedPromptEngine(t, files)
|
||||||
|
files["config/body.tmpl"] = &fstest.MapFile{Data: []byte("after {{.name}}")}
|
||||||
|
request := promptkit.RunRequest{PromptID: "captured", Vars: map[string]string{"name": "Ada", "session": "session-1"}}
|
||||||
|
oldPrepared, err := first.Prepare(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare with existing engine: %v", err)
|
||||||
|
}
|
||||||
|
second := capturedPromptEngine(t, files)
|
||||||
|
newPrepared, err := second.Prepare(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare with rebuilt engine: %v", err)
|
||||||
|
}
|
||||||
|
if oldPrepared.Messages[0].Content != "before Ada" || newPrepared.Messages[0].Content != "after Ada" || oldPrepared.PromptHash == newPrepared.PromptHash {
|
||||||
|
t.Fatalf("captured content or hash did not follow engine lifetime: old=%+v new=%+v", oldPrepared, newPrepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type unreadableAfterConstructionFS struct {
|
||||||
|
fs.FS
|
||||||
|
closed atomic.Bool
|
||||||
|
reads atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *unreadableAfterConstructionFS) Open(name string) (fs.File, error) {
|
||||||
|
if source.closed.Load() {
|
||||||
|
source.reads.Add(1)
|
||||||
|
return nil, fs.ErrPermission
|
||||||
|
}
|
||||||
|
return source.FS.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapturedTemplatesBindInputsIndependentlyAcrossConcurrentPreparations(t *testing.T) {
|
||||||
|
source := &unreadableAfterConstructionFS{FS: fstest.MapFS{
|
||||||
|
"config/prompt.yaml": &fstest.MapFile{Data: []byte(capturedPromptDocument)},
|
||||||
|
"config/body.tmpl": &fstest.MapFile{Data: []byte(`{{.name}}: {{input "note"}}`)},
|
||||||
|
}}
|
||||||
|
engine := capturedPromptEngine(t, source)
|
||||||
|
source.closed.Store(true)
|
||||||
|
|
||||||
|
const calls = 24
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
errs := make(chan error, calls)
|
||||||
|
for index := 0; index < calls; index++ {
|
||||||
|
wait.Add(1)
|
||||||
|
go func(index int) {
|
||||||
|
defer wait.Done()
|
||||||
|
name := fmt.Sprintf("caller-%d", index)
|
||||||
|
body := fmt.Sprintf("body-%d", index)
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "captured", SessionID: "direct-session",
|
||||||
|
Vars: map[string]string{"name": name},
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{"note": promptkit.Inline(body)},
|
||||||
|
})
|
||||||
|
if err == nil && (prepared.Messages[0].Content != name+": "+body || prepared.SessionID != "direct-session") {
|
||||||
|
err = fmt.Errorf("unexpected prepared content: %+v", prepared)
|
||||||
|
}
|
||||||
|
errs <- err
|
||||||
|
}(index)
|
||||||
|
}
|
||||||
|
wait.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source.reads.Load() != 0 {
|
||||||
|
t.Fatalf("preparation reopened captured source %d times", source.reads.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "captured", Vars: map[string]string{"name": "Ada"},
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{"note": promptkit.Inline("body")},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, promptkit.ErrPromptRender) {
|
||||||
|
t.Fatalf("missing session variable = %v, want runtime render error", err)
|
||||||
|
}
|
||||||
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "captured", SessionID: "direct-session", Vars: map[string]string{"name": "Ada"},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, promptkit.ErrPromptRender) {
|
||||||
|
t.Fatalf("missing input helper artifact = %v, want runtime render error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparationStillReadsRequestArtifactsAndSchemasAfterConstruction(t *testing.T) {
|
||||||
|
endpoint, model := "https://example.invalid/v1", "offline-model"
|
||||||
|
schemas := fstest.MapFS{}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{
|
||||||
|
"offline": {Endpoint: &endpoint, Model: &model},
|
||||||
|
})}}}},
|
||||||
|
Main: &configkit.Input{Source: configkit.Mapping("structured prompt", map[string]any{
|
||||||
|
"prompts": map[string]any{"structured": map[string]any{"1": map[string]any{
|
||||||
|
"default_profile": "offline",
|
||||||
|
"inputs": []any{map[string]any{"name": "note", "required": true}},
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": `{{input "note"}}`}},
|
||||||
|
"output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"},
|
||||||
|
}}},
|
||||||
|
})},
|
||||||
|
}}, promptkit.WithSchemaFS(schemas, "."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine before request resources exist: %v", err)
|
||||||
|
}
|
||||||
|
schemas["schema.json"] = &fstest.MapFile{Data: []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`)}
|
||||||
|
artifactPath := filepath.Join(t.TempDir(), "note.txt")
|
||||||
|
if err := os.WriteFile(artifactPath, []byte("request artifact"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write request artifact: %v", err)
|
||||||
|
}
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "structured", Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.File(artifactPath),
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare with resources supplied after construction: %v", err)
|
||||||
|
}
|
||||||
|
if len(prepared.Messages) != 1 || prepared.Messages[0].Content != "request artifact" || prepared.StructuredOutput == nil || prepared.StructuredOutput.JSONSchema == nil {
|
||||||
|
t.Fatalf("runtime artifact or schema was not prepared: %+v", prepared)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,3 +333,15 @@ func (state *loaded) GetPromptDefinition(ctx context.Context, id, version string
|
|||||||
definition, _, err := state.GetPromptSnapshot(ctx, id, version)
|
definition, _, err := state.GetPromptSnapshot(ctx, id, version)
|
||||||
return definition, err
|
return definition, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCompiledTemplates returns construction-time parses for one exact prompt.
|
||||||
|
func (state *loaded) GetCompiledTemplates(id, version string) (prompt.CompiledTemplates, bool) {
|
||||||
|
if state == nil {
|
||||||
|
return prompt.CompiledTemplates{}, false
|
||||||
|
}
|
||||||
|
entry := state.prompts[id][version]
|
||||||
|
if entry == nil {
|
||||||
|
return prompt.CompiledTemplates{}, false
|
||||||
|
}
|
||||||
|
return prompt.CompiledTemplates{Session: entry.session, Messages: entry.messages}, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func New(cfg Config, options Options) (*Engine, error) {
|
|||||||
reader = artifact.NewCompositeReader()
|
reader = artifact.NewCompositeReader()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Engine{runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, snapshot, reader, prompt.NewGoRenderer(), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil
|
return &Engine{runner: usecase.NewRunnerWithRepairer(snapshot, snapshot, snapshot, reader, prompt.NewCompiledRenderer(snapshot), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSourceFS(fsys fs.FS, root string) error {
|
func validateSourceFS(fsys fs.FS, root string) error {
|
||||||
|
|||||||
@@ -21,12 +21,20 @@ var (
|
|||||||
|
|
||||||
const artifactTextChunkSize = 64 * 1024
|
const artifactTextChunkSize = 64 * 1024
|
||||||
|
|
||||||
type goRenderer struct{}
|
type goRenderer struct {
|
||||||
|
templates TemplateLookup
|
||||||
|
}
|
||||||
|
|
||||||
func NewGoRenderer() Renderer {
|
func NewGoRenderer() Renderer {
|
||||||
return &goRenderer{}
|
return &goRenderer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCompiledRenderer renders definitions using templates captured with the
|
||||||
|
// configuration snapshot. Per-call clones bind request-specific input helpers.
|
||||||
|
func NewCompiledRenderer(templates TemplateLookup) Renderer {
|
||||||
|
return &goRenderer{templates: templates}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -49,11 +57,30 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini
|
|||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"input": resolver.resolve,
|
"input": resolver.resolve,
|
||||||
}
|
}
|
||||||
|
var compiled CompiledTemplates
|
||||||
|
if r.templates != nil {
|
||||||
|
var found bool
|
||||||
|
compiled, found = r.templates.GetCompiledTemplates(definition.ID, definition.Version)
|
||||||
|
if !found || len(compiled.Messages) != len(definition.Templates) {
|
||||||
|
return nil, fmt.Errorf("%w: captured templates are unavailable", ErrRenderFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sessionID, err := renderSessionID(ctx, definition.SessionID, funcs, vars)
|
var sessionID string
|
||||||
|
var err error
|
||||||
|
if r.templates != nil {
|
||||||
|
if definition.SessionID != "" {
|
||||||
|
if compiled.Session == nil {
|
||||||
|
return nil, fmt.Errorf("%w: captured session template is unavailable", ErrRenderFailure)
|
||||||
|
}
|
||||||
|
sessionID, err = renderCompiledSessionID(ctx, compiled.Session, funcs, vars)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionID, err = renderSessionID(ctx, definition.SessionID, funcs, vars)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,7 +103,19 @@ 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 := parseTemplate(fmt.Sprintf("msg_%d", i), tmplMsg.Content, funcs)
|
var tmpl *template.Template
|
||||||
|
var parseErr error
|
||||||
|
if r.templates != nil {
|
||||||
|
if compiled.Messages[i] == nil {
|
||||||
|
return nil, fmt.Errorf("%w: captured message template is unavailable", ErrRenderFailure)
|
||||||
|
}
|
||||||
|
tmpl, parseErr = compiled.Messages[i].Clone()
|
||||||
|
if parseErr == nil {
|
||||||
|
tmpl.Funcs(funcs)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -174,7 +213,25 @@ func renderSessionID(ctx context.Context, raw string, funcs template.FuncMap, va
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, parseErr)
|
return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, parseErr)
|
||||||
}
|
}
|
||||||
|
return executeSessionTemplate(ctx, tmpl, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCompiledSessionID(ctx context.Context, compiled *template.Template, funcs template.FuncMap, vars map[string]string) (string, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpl, err := compiled.Clone()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %v", ErrRenderFailure, err)
|
||||||
|
}
|
||||||
|
tmpl.Funcs(funcs)
|
||||||
|
return executeSessionTemplate(ctx, tmpl, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeSessionTemplate(ctx context.Context, tmpl *template.Template, vars map[string]string) (string, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ package prompt
|
|||||||
|
|
||||||
import "text/template"
|
import "text/template"
|
||||||
|
|
||||||
|
// CompiledTemplates is the immutable parsed template set for one captured
|
||||||
|
// prompt. Callers must not mutate its templates after handing it to a renderer.
|
||||||
|
type CompiledTemplates struct {
|
||||||
|
Session *template.Template
|
||||||
|
Messages []*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateLookup provides parsed templates for a captured prompt identity.
|
||||||
|
type TemplateLookup interface {
|
||||||
|
GetCompiledTemplates(id, version string) (CompiledTemplates, bool)
|
||||||
|
}
|
||||||
|
|
||||||
// CompileTemplate checks static syntax with the same grammar and function
|
// CompileTemplate checks static syntax with the same grammar and function
|
||||||
// names as rendering, without evaluating request inputs or variables.
|
// names as rendering, without evaluating request inputs or variables.
|
||||||
func CompileTemplate(name, raw string) (*template.Template, error) {
|
func CompileTemplate(name, raw string) (*template.Template, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user