// Package prompts stores named, versioned prompt templates and renders them // with structured data. Untrusted content is always wrapped in delimited data // blocks with an explicit instruction that it is data (T1, T2). package prompts import ( "bytes" "context" "embed" "encoding/json" "errors" "fmt" "io/fs" "strings" "text/template" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/andydixon/noema/internal/llm" "github.com/andydixon/noema/internal/storage" "github.com/andydixon/noema/pkg/ids" ) //go:embed defaults/*.tmpl var defaults embed.FS // Prompt is a named template with its current version content. type Prompt struct { Name string `json:"name"` Description string `json:"description"` Role llm.Role `json:"role"` CurrentVersion int `json:"current_version"` UpdatedAt time.Time `json:"updated_at"` System string `json:"system"` Template string `json:"template"` Schema json.RawMessage `json:"schema,omitempty"` } // Version is one revision. type Version struct { ID ids.ID `json:"id"` Name string `json:"name"` Version int `json:"version"` System string `json:"system"` Template string `json:"template"` Schema json.RawMessage `json:"schema,omitempty"` Note string `json:"note"` CreatedBy string `json:"created_by"` CreatedAt time.Time `json:"created_at"` } // ErrInvalid marks bad input. var ErrInvalid = errors.New("prompts: invalid") // Store persists prompts. type Store struct{ db *pgxpool.Pool } // NewStore creates a store. func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} } // funcs are available inside templates. "data" is the only way untrusted text // should enter a prompt. var funcs = template.FuncMap{ "data": DataBlock, "join": strings.Join, "printf": fmt.Sprintf, } // DataBlock wraps untrusted text so the model treats it as content. The // delimiters are unusual tokens and any occurrence inside the text is escaped, // so injected text cannot close the block early. func DataBlock(label, text string) string { text = strings.ReplaceAll(text, "<<<", "‹‹‹") text = strings.ReplaceAll(text, ">>>", "›››") return fmt.Sprintf("<<>>\n%s\n<<>>", label, text, label) } // Parse validates a template body. func Parse(name, body string) (*template.Template, error) { t, err := template.New(name).Funcs(funcs).Option("missingkey=zero").Parse(body) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalid, err) } return t, nil } // Render executes a prompt's system and user templates with data and returns // a Request skeleton carrying prompt provenance and schema. func (p Prompt) Render(data any) (llm.Request, error) { sys, err := render(p.Name+".system", p.System, data) if err != nil { return llm.Request{}, err } user, err := render(p.Name+".user", p.Template, data) if err != nil { return llm.Request{}, err } req := llm.Request{Role: p.Role, PromptName: p.Name, PromptVersion: p.CurrentVersion, System: strings.TrimSpace(sys), Messages: []llm.Message{{Role: "user", Content: strings.TrimSpace(user)}}} if len(p.Schema) > 0 { req.JSONSchema = p.Schema } return req, nil } func render(name, body string, data any) (string, error) { t, err := Parse(name, body) if err != nil { return "", err } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { return "", fmt.Errorf("%w: %v", ErrInvalid, err) } return buf.String(), nil } // Get returns a prompt with its current version. func (s *Store) Get(ctx context.Context, name string) (Prompt, error) { var p Prompt var role string var schema *json.RawMessage err := s.db.QueryRow(ctx, `SELECT p.name, p.description, p.role, p.current_version, p.updated_at, v.system, v.template, v.schema FROM prompts p JOIN prompt_versions v ON v.name=p.name AND v.version=p.current_version WHERE p.name=$1`, name). Scan(&p.Name, &p.Description, &role, &p.CurrentVersion, &p.UpdatedAt, &p.System, &p.Template, &schema) if err != nil { return p, storage.Translate(err) } p.Role = llm.Role(role) if schema != nil { p.Schema = *schema } return p, nil } // List returns all prompts. func (s *Store) List(ctx context.Context) ([]Prompt, error) { rows, err := s.db.Query(ctx, `SELECT p.name, p.description, p.role, p.current_version, p.updated_at, v.system, v.template, v.schema FROM prompts p JOIN prompt_versions v ON v.name=p.name AND v.version=p.current_version ORDER BY p.name`) if err != nil { return nil, err } defer rows.Close() var out []Prompt for rows.Next() { var p Prompt var role string var schema *json.RawMessage if err := rows.Scan(&p.Name, &p.Description, &role, &p.CurrentVersion, &p.UpdatedAt, &p.System, &p.Template, &schema); err != nil { return nil, err } p.Role = llm.Role(role) if schema != nil { p.Schema = *schema } out = append(out, p) } return out, rows.Err() } // Versions lists a prompt's history, newest first. func (s *Store) Versions(ctx context.Context, name string) ([]Version, error) { rows, err := s.db.Query(ctx, `SELECT id, name, version, system, template, schema, note, created_by, created_at FROM prompt_versions WHERE name=$1 ORDER BY version DESC`, name) if err != nil { return nil, err } defer rows.Close() var out []Version for rows.Next() { var v Version var schema *json.RawMessage if err := rows.Scan(&v.ID, &v.Name, &v.Version, &v.System, &v.Template, &schema, &v.Note, &v.CreatedBy, &v.CreatedAt); err != nil { return nil, err } if schema != nil { v.Schema = *schema } out = append(out, v) } return out, rows.Err() } // GetVersion fetches one revision. func (s *Store) GetVersion(ctx context.Context, name string, version int) (Version, error) { var v Version var schema *json.RawMessage err := s.db.QueryRow(ctx, `SELECT id, name, version, system, template, schema, note, created_by, created_at FROM prompt_versions WHERE name=$1 AND version=$2`, name, version). Scan(&v.ID, &v.Name, &v.Version, &v.System, &v.Template, &schema, &v.Note, &v.CreatedBy, &v.CreatedAt) if err != nil { return v, storage.Translate(err) } if schema != nil { v.Schema = *schema } return v, nil } // Input is a new prompt or version. type Input struct { Name string Description string Role llm.Role System string Template string Schema json.RawMessage Note string By string } func (in *Input) validate() error { in.Name = strings.TrimSpace(in.Name) if in.Name == "" || len(in.Name) > 80 || strings.ContainsAny(in.Name, " /\\") { return fmt.Errorf("%w: name required (no spaces or slashes)", ErrInvalid) } if strings.TrimSpace(in.Template) == "" { return fmt.Errorf("%w: template required", ErrInvalid) } if _, err := Parse(in.Name, in.Template); err != nil { return err } if _, err := Parse(in.Name+".system", in.System); err != nil { return err } if len(in.Schema) > 0 && !json.Valid(in.Schema) { return fmt.Errorf("%w: schema is not valid JSON", ErrInvalid) } if len(in.Schema) == 0 { in.Schema = nil } return nil } // Save creates a prompt or appends a new version to an existing one. func (s *Store) Save(ctx context.Context, in Input) (Prompt, error) { if err := in.validate(); err != nil { return Prompt{}, err } err := storage.Tx(ctx, s.db, func(tx pgx.Tx) error { var cur int var role string err := tx.QueryRow(ctx, `SELECT current_version, role FROM prompts WHERE name=$1 FOR UPDATE`, in.Name).Scan(&cur, &role) if errors.Is(err, pgx.ErrNoRows) { if in.Role == "" { return fmt.Errorf("%w: role required for a new prompt", ErrInvalid) } if _, err := tx.Exec(ctx, `INSERT INTO prompts (name, description, role, current_version) VALUES ($1,$2,$3,0)`, in.Name, in.Description, string(in.Role)); err != nil { return err } cur = 0 } else if err != nil { return err } next := cur + 1 if _, err := tx.Exec(ctx, `INSERT INTO prompt_versions (id, name, version, system, template, schema, note, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, ids.New(), in.Name, next, in.System, in.Template, nullRaw(in.Schema), in.Note, in.By); err != nil { return err } desc := in.Description _, err = tx.Exec(ctx, `UPDATE prompts SET current_version=$2, description=CASE WHEN $3='' THEN description ELSE $3 END, role=CASE WHEN $4='' THEN role ELSE $4 END, updated_at=now() WHERE name=$1`, in.Name, next, desc, string(in.Role)) return err }) if err != nil { return Prompt{}, err } return s.Get(ctx, in.Name) } func nullRaw(r json.RawMessage) *json.RawMessage { if len(r) == 0 { return nil } return &r } // Restore makes an old version current by appending a copy of it. func (s *Store) Restore(ctx context.Context, name string, version int, by string) (Prompt, error) { v, err := s.GetVersion(ctx, name, version) if err != nil { return Prompt{}, err } return s.Save(ctx, Input{Name: name, System: v.System, Template: v.Template, Schema: v.Schema, Note: fmt.Sprintf("restored version %d", version), By: by}) } // Clone copies the current version of a prompt under a new name. func (s *Store) Clone(ctx context.Context, name, newName, by string) (Prompt, error) { p, err := s.Get(ctx, name) if err != nil { return Prompt{}, err } return s.Save(ctx, Input{Name: newName, Description: p.Description + " (clone of " + name + ")", Role: p.Role, System: p.System, Template: p.Template, Schema: p.Schema, Note: "cloned from " + name, By: by}) } // SeedDefaults installs the embedded default prompts that do not exist yet. // Existing prompts are never overwritten, so operator edits survive upgrades. func (s *Store) SeedDefaults(ctx context.Context) (int, error) { entries, err := fs.ReadDir(defaults, "defaults") if err != nil { return 0, err } n := 0 for _, e := range entries { name := strings.TrimSuffix(e.Name(), ".tmpl") if _, err := s.Get(ctx, name); err == nil { continue } raw, err := fs.ReadFile(defaults, "defaults/"+e.Name()) if err != nil { return n, err } in, err := parseDefault(name, string(raw)) if err != nil { return n, fmt.Errorf("default prompt %s: %w", name, err) } if _, err := s.Save(ctx, in); err != nil { return n, fmt.Errorf("default prompt %s: %w", name, err) } n++ } return n, nil } // parseDefault reads the tiny front matter used by embedded prompts: // role, description, schema (one line of JSON) and system (a "|" block). func parseDefault(name, raw string) (Input, error) { in := Input{Name: name, Note: "built-in default", By: "noema"} if !strings.HasPrefix(raw, "---\n") { return in, errors.New("missing front matter") } rest := raw[4:] end := strings.Index(rest, "\n---\n") if end < 0 { return in, errors.New("unterminated front matter") } front, body := rest[:end], rest[end+5:] lines := strings.Split(front, "\n") for i := 0; i < len(lines); i++ { line := lines[i] key, val, ok := strings.Cut(line, ":") if !ok { continue } key, val = strings.TrimSpace(key), strings.TrimSpace(val) switch key { case "role": in.Role = llm.Role(val) case "description": in.Description = val case "schema": in.Schema = json.RawMessage(val) case "system": if val == "|" { var b []string for i+1 < len(lines) && strings.HasPrefix(lines[i+1], " ") { i++ b = append(b, strings.TrimPrefix(lines[i], " ")) } in.System = strings.Join(b, "\n") } else { in.System = val } } } in.Template = strings.TrimSpace(body) return in, nil }