diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bba6f84 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,48 @@ +{ + "permissions": { + "allow": [ + "Bash(git log:*)", + "Bash(git branch:*)", + "Bash(git remote:*)", + "Bash(git fetch:*)", + "Bash(git stash:*)", + "Bash(git diff:*)", + "Bash(git status:*)", + "Bash(git show:*)", + "Bash(gh repo view:*)", + "Bash(gh run list:*)", + "Bash(gh run view:*)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr list:*)", + "Bash(gh issue view:*)", + "Bash(gh release:*)", + "Bash(gh api:*)", + "Bash(rake -T:*)", + "Bash(bundle show:*)", + "Bash(bundle list:*)", + "Bash(bundle platform:*)", + "Bash(gem search:*)", + "Bash(gem list:*)", + "Bash(tree:*)", + "Bash(grep:*)", + "Bash(find:*)", + "Bash(echo:*)", + "Bash(sort:*)", + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(less:*)", + "Bash(wc:*)", + "Bash(ls:*)", + "Bash(pwd:*)", + "Bash(which:*)", + "Bash(type:*)", + "Bash(file:*)", + "Bash(VCR_MODE=new_episodes bundle exec ruby:*)", + "Bash(VCR_MODE=new_episodes bundle exec rake:*)", + "Bash(VCR_MODE=new_episodes rake test:*)" + ], + "deny": [] + } +} diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..2bbb355 --- /dev/null +++ b/.standard.yml @@ -0,0 +1,5 @@ +# StandardRB configuration +# https://github.com/standardrb/standard + +ignore: + - "lib/braintrust/vendor/**/*" # Vendored code follows upstream conventions diff --git a/examples/prompt.rb b/examples/prompt.rb new file mode 100644 index 0000000..f61d541 --- /dev/null +++ b/examples/prompt.rb @@ -0,0 +1,100 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Example: Loading and executing prompts from Braintrust +# +# This example demonstrates how to: +# 1. Create a prompt (function) on the Braintrust server +# 2. Load it using Prompt.load +# 3. Build the prompt with Mustache variable substitution +# 4. Execute the prompt with OpenAI and get a response +# +# Benefits of loading prompts: +# - Centralized prompt management in Braintrust UI +# - Version control and A/B testing for prompts +# - No code deployment needed for prompt changes +# - Works with any LLM client (OpenAI, Anthropic, etc.) +# - Uses standard Mustache templating ({{variable}}, {{object.property}}) + +require "bundler/setup" +require "braintrust" +require "openai" + +# Initialize Braintrust (auto-instruments OpenAI automatically) +Braintrust.init + +# Create OpenAI client (auto-instrumented for tracing) +openai = OpenAI::Client.new + +project_name = "ruby-sdk-examples" +prompt_slug = "greeting-prompt-#{Time.now.to_i}" + +# First, create a prompt on the server +# In practice, you would create prompts via the Braintrust UI +puts "Creating prompt..." + +api = Braintrust::API.new +api.functions.create( + project_name: project_name, + slug: prompt_slug, + function_data: {type: "prompt"}, + prompt_data: { + prompt: { + type: "chat", + messages: [ + { + role: "system", + content: "You are a friendly assistant. Respond in {{language}}. Keep responses brief (1-2 sentences)." + }, + { + role: "user", + content: "Say hello to {{name}} and wish them a great {{time_of_day}}!" + } + ] + }, + options: { + model: "gpt-4o-mini", + params: {temperature: 0.7, max_tokens: 100} + } + } +) +puts "Created prompt: #{prompt_slug}" + +# Load the prompt using Prompt.load +puts "\nLoading prompt..." +prompt = Braintrust::Prompt.load(project: project_name, slug: prompt_slug) + +puts " ID: #{prompt.id}" +puts " Slug: #{prompt.slug}" +puts " Model: #{prompt.model}" + +# Build the prompt with Mustache variable substitution +puts "\nBuilding prompt with variables..." +params = prompt.build( + name: "Alice", + language: "Spanish", + time_of_day: "morning" +) + +puts " Model: #{params[:model]}" +puts " Temperature: #{params[:temperature]}" +puts " Messages:" +params[:messages].each do |msg| + puts " [#{msg[:role]}] #{msg[:content]}" +end + +# Execute the prompt with OpenAI +puts "\nExecuting prompt with OpenAI..." +response = openai.chat.completions.create(**params) + +puts "\nResponse:" +content = response.choices.first.message.content +puts " #{content}" + +# Clean up - delete the test prompt +puts "\nCleaning up..." +api.functions.delete(id: prompt.id) +puts "Done!" + +# Flush traces +OpenTelemetry.tracer_provider.shutdown diff --git a/lib/braintrust.rb b/lib/braintrust.rb index ba023fc..7a61327 100644 --- a/lib/braintrust.rb +++ b/lib/braintrust.rb @@ -5,6 +5,7 @@ require_relative "braintrust/state" require_relative "braintrust/trace" require_relative "braintrust/api" +require_relative "braintrust/prompt" require_relative "braintrust/internal/experiments" require_relative "braintrust/internal/env" require_relative "braintrust/eval" diff --git a/lib/braintrust/api/functions.rb b/lib/braintrust/api/functions.rb index ccfea31..1d569d9 100644 --- a/lib/braintrust/api/functions.rb +++ b/lib/braintrust/api/functions.rb @@ -85,6 +85,17 @@ def invoke(id:, input:) http_post_json("/v1/function/#{id}/invoke", payload) end + # Get a function by ID (includes full prompt_data) + # GET /v1/function/{id} + # @param id [String] Function UUID + # @param version [String, nil] Retrieve prompt at a specific version (transaction ID or version identifier) + # @return [Hash] Full function data including prompt_data + def get(id:, version: nil) + params = {} + params["version"] = version if version + http_get("/v1/function/#{id}", params) + end + # Delete a function by ID # DELETE /v1/function/{id} # @param id [String] Function UUID diff --git a/lib/braintrust/internal/template.rb b/lib/braintrust/internal/template.rb new file mode 100644 index 0000000..fd9d54a --- /dev/null +++ b/lib/braintrust/internal/template.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "../vendor/mustache" + +module Braintrust + module Internal + # Template rendering utilities for Mustache templates + module Template + module_function + + # Render a template string with variable substitution + # + # @param text [String] Template text to render + # @param variables [Hash] Variables to substitute + # @param format [String] Template format: "mustache", "none", or "nunjucks" + # @param strict [Boolean] Raise error on missing variables (default: false) + # @return [String] Rendered text + def render(text, variables, format:, strict: false) + return text unless text.is_a?(String) + + case format + when "none" + # No templating - return text unchanged + text + when "nunjucks" + # Nunjucks is a UI-only feature in Braintrust + raise Error, "Nunjucks templates are not supported in the Ruby SDK. " \ + "Nunjucks only works in Braintrust playgrounds. " \ + "Please use 'mustache' or 'none' template format, or invoke the prompt via the API proxy." + when "mustache", "", nil + # Default: Mustache templating + if strict + missing = find_missing_variables(text, variables) + if missing.any? + raise Error, "Missing required variables: #{missing.join(", ")}" + end + end + + Vendor::Mustache.render(text, variables) + else + raise Error, "Unknown template format: #{format.inspect}. " \ + "Supported formats are 'mustache' and 'none'." + end + end + + # Find Mustache variables in template that are not provided + # + # @param text [String] Template text + # @param variables [Hash] Available variables + # @return [Array] List of missing variable names + def find_missing_variables(text, variables) + # Extract {{variable}} and {{variable.path}} patterns + # Mustache uses {{name}} syntax + text.scan(/\{\{([^}#^\/!>]+)\}\}/).flatten.map(&:strip).uniq.reject do |var| + resolve_variable(var, variables) + end + end + + # Check if a variable path exists in a hash + # + # @param path [String] Dot-separated variable path (e.g., "user.name") + # @param variables [Hash] Variables to search + # @return [Object, nil] The value if found, nil otherwise + def resolve_variable(path, variables) + parts = path.split(".") + value = variables + + parts.each do |part| + return nil unless value.is_a?(Hash) + # Try both string and symbol keys + value = value[part] || value[part.to_sym] + return nil if value.nil? + end + + value + end + + # Convert hash keys to strings recursively + # + # @param hash [Hash] Hash with symbol or string keys + # @return [Hash] Hash with all string keys + def stringify_keys(hash) + return {} unless hash.is_a?(Hash) + + hash.transform_keys(&:to_s).transform_values do |v| + v.is_a?(Hash) ? stringify_keys(v) : v + end + end + end + end +end diff --git a/lib/braintrust/prompt.rb b/lib/braintrust/prompt.rb new file mode 100644 index 0000000..2125075 --- /dev/null +++ b/lib/braintrust/prompt.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "json" +require_relative "internal/template" + +module Braintrust + # Prompt class for loading and building prompts from Braintrust + # + # @example Load and use a prompt + # prompt = Braintrust::Prompt.load(project: "my-project", slug: "summarizer") + # params = prompt.build(text: "Article to summarize...") + # client.messages.create(**params) + class Prompt + attr_reader :id, :name, :slug, :project_id + + # Load a prompt from Braintrust + # + # @param project [String] Project name + # @param slug [String] Prompt slug + # @param version [String, nil] Specific version (default: latest) + # @param defaults [Hash] Default variable values for build() + # @param api [API, nil] Braintrust API client (default: creates one using global state) + # @return [Prompt] + def self.load(project:, slug:, version: nil, defaults: {}, api: nil) + api ||= API.new + + # Find the function by project + slug + result = api.functions.list(project_name: project, slug: slug) + function = result.dig("objects")&.first + raise Error, "Prompt '#{slug}' not found in project '#{project}'" unless function + + # Fetch full function data including prompt_data + full_data = api.functions.get(id: function["id"], version: version) + + new(full_data, defaults: defaults) + end + + # Initialize a Prompt from function data + # + # @param data [Hash] Function data from API + # @param defaults [Hash] Default variable values for build() + def initialize(data, defaults: {}) + @data = data + @defaults = Internal::Template.stringify_keys(defaults) + + @id = data["id"] + @name = data["name"] + @slug = data["slug"] + @project_id = data["project_id"] + end + + # Get the raw prompt definition + # @return [Hash, nil] + def prompt + @data.dig("prompt_data", "prompt") + end + + # Get the prompt messages + # @return [Array] + def messages + prompt&.dig("messages") || [] + end + + # Get the tools definition (parsed from JSON string) + # @return [Array, nil] + def tools + tools_json = prompt&.dig("tools") + return nil unless tools_json.is_a?(String) && !tools_json.empty? + + JSON.parse(tools_json) + rescue JSON::ParserError + nil + end + + # Get the model name + # @return [String, nil] + def model + @data.dig("prompt_data", "options", "model") + end + + # Get model options + # @return [Hash] + def options + @data.dig("prompt_data", "options") || {} + end + + # Get the template format + # @return [String] "mustache" (default), "nunjucks", or "none" + def template_format + @data.dig("prompt_data", "template_format") || "mustache" + end + + # Build the prompt with variable substitution + # + # Returns a hash ready to pass to an LLM client: + # {model: "...", messages: [...], temperature: ..., ...} + # + # @param variables [Hash] Variables to substitute (e.g., {name: "Alice"}) + # @param strict [Boolean] Raise error on missing variables (default: false) + # @return [Hash] Built prompt ready for LLM client + # + # @example With keyword arguments + # prompt.build(name: "Alice", task: "coding") + # + # @example With explicit hash + # prompt.build({name: "Alice"}, strict: true) + def build(variables = nil, strict: false, **kwargs) + # Support both explicit hash and keyword arguments + variables_hash = variables.is_a?(Hash) ? variables : {} + vars = @defaults + .merge(Internal::Template.stringify_keys(variables_hash)) + .merge(Internal::Template.stringify_keys(kwargs)) + + # Render Mustache templates in messages + built_messages = messages.map do |msg| + { + role: msg["role"].to_sym, + content: Internal::Template.render(msg["content"], vars, format: template_format, strict: strict) + } + end + + # Build result with model and messages + result = { + model: model, + messages: built_messages + } + + # Add tools if defined + parsed_tools = tools + result[:tools] = parsed_tools if parsed_tools + + # Add params (temperature, max_tokens, etc.) to top level + params = options.dig("params") + if params.is_a?(Hash) + params.each do |key, value| + result[key.to_sym] = value + end + end + + result + end + end +end diff --git a/lib/braintrust/vendor/mustache.rb b/lib/braintrust/vendor/mustache.rb new file mode 100644 index 0000000..4267497 --- /dev/null +++ b/lib/braintrust/vendor/mustache.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Vendored Mustache template engine +# From mustache gem v1.1.1 - https://github.com/mustache/mustache +# License: MIT +# +# Modifications from original: +# - Namespaced under Braintrust::Vendor to avoid conflicts +# - Disabled HTML escaping (LLM prompts don't need HTML entity encoding) +# +# This vendored version ensures: +# - No external dependency required +# - Consistent behavior across all SDK users +# - No HTML escaping that would corrupt prompts containing < > & characters + +require_relative "mustache/mustache" diff --git a/lib/braintrust/vendor/mustache/context.rb b/lib/braintrust/vendor/mustache/context.rb new file mode 100644 index 0000000..fd65d91 --- /dev/null +++ b/lib/braintrust/vendor/mustache/context.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +require_relative "context_miss" + +module Braintrust + module Vendor + class Mustache + # A Context represents the context which a Mustache template is + # executed within. All Mustache tags reference keys in the Context. + class Context + # Initializes a Mustache::Context. + # + # @param [Mustache] mustache A Mustache instance. + # + def initialize(mustache) + @stack = [mustache] + @partial_template_cache = {} + end + + # A {{>partial}} tag translates into a call to the context's + # `partial` method, which would be this sucker right here. + # + # If the Mustache view handling the rendering (e.g. the view + # representing your profile page or some other template) responds + # to `partial`, we call it and render the result. + # + def partial(name, indentation = "") + # Look for the first Mustache in the stack. + mustache = mustache_in_stack + + # Indent the partial template by the given indentation. + part = mustache.partial(name).to_s.gsub(/^/, indentation) + + # Get a template object for the partial and render the result. + template_for_partial(part).render(self) + end + + def template_for_partial(partial) + @partial_template_cache[partial] ||= Template.new(partial) + end + + # Find the first Mustache in the stack. + # + # If we're being rendered inside a Mustache object as a context, + # we'll use that one. + # + # @return [Mustache] First Mustache in the stack. + # + def mustache_in_stack + @mustache_in_stack ||= @stack.find { |frame| frame.is_a?(Mustache) } + end + + # Allows customization of how Mustache escapes things. + # + # @param [Object] value Value to escape. + # + # @return [String] Escaped string. + # + def escape(value) + mustache_in_stack.escape(value) + end + + # Adds a new object to the context's internal stack. + # + # @param [Object] new_obj Object to be added to the internal stack. + # + # @return [Context] Returns the Context. + # + def push(new_obj) + @stack.unshift(new_obj) + @mustache_in_stack = nil + self + end + + # Removes the most recently added object from the context's + # internal stack. + # + # @return [Context] Returns the Context. + # + def pop + @stack.shift + @mustache_in_stack = nil + self + end + + # Can be used to add a value to the context in a hash-like way. + # + # context[:name] = "Chris" + def []=(name, value) + push(name => value) + end + + # Alias for `fetch`. + def [](name) + fetch(name, nil) + end + + # Do we know about a particular key? In other words, will calling + # `context[key]` give us a result that was set. Basically. + def has_key?(key) + fetch(key, false) + rescue ContextMiss + false + end + + # Similar to Hash#fetch, finds a value by `name` in the context's + # stack. You may specify the default return value by passing a + # second parameter. + # + # If no second parameter is passed (or raise_on_context_miss is + # set to true), will raise a ContextMiss exception on miss. + def fetch(name, default = :__raise) + @stack.each do |frame| + # Prevent infinite recursion. + next if frame == self + + value = find(frame, name, :__missing) + return value if :__missing != value + end + + if default == :__raise || mustache_in_stack.raise_on_context_miss? + raise ContextMiss.new("Can't find #{name} in #{@stack.inspect}") + else + default + end + end + + # Finds a key in an object, using whatever method is most + # appropriate. If the object is a hash, does a simple hash lookup. + # If it's an object that responds to the key as a method call, + # invokes that method. You get the idea. + # + # @param [Object] obj The object to perform the lookup on. + # @param [String,Symbol] key The key whose value you want + # @param [Object] default An optional default value, to return if the key is not found. + # + # @return [Object] The value of key in object if it is found, and default otherwise. + # + def find(obj, key, default = nil) + return find_in_hash(obj.to_hash, key, default) if obj.respond_to?(:to_hash) + + unless obj.respond_to?(key) + # no match for the key, but it may include a hyphen, so try again replacing hyphens with underscores. + key = key.to_s.tr("-", "_") + return default unless obj.respond_to?(key) + end + + meth = obj.method(key) rescue proc { obj.send(key) } + meth.arity == 1 ? meth.to_proc : meth.call + end + + def current + @stack.first + end + + private + + # Fetches a hash key if it exists, or returns the given default. + def find_in_hash(obj, key, default) + return obj[key] if obj.has_key?(key) + return obj[key.to_s] if obj.has_key?(key.to_s) + return obj[key] if obj.respond_to?(:default_proc) && obj.default_proc && obj[key] + + # If default is :__missing then we are from #fetch which is hunting through the stack + # If default is nil then we are reducing dot notation + if :__missing != default && mustache_in_stack.raise_on_context_miss? + raise ContextMiss.new("Can't find #{key} in #{obj}") + else + obj.fetch(key, default) + end + end + end + end + end +end diff --git a/lib/braintrust/vendor/mustache/context_miss.rb b/lib/braintrust/vendor/mustache/context_miss.rb new file mode 100644 index 0000000..d30b8ef --- /dev/null +++ b/lib/braintrust/vendor/mustache/context_miss.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +module Braintrust + module Vendor + class Mustache + # A ContextMiss is raised whenever a tag's target can not be found + # in the current context if `Mustache#raise_on_context_miss?` is + # set to true. + # + # For example, if your View class does not respond to `music` but + # your template contains a `{{music}}` tag this exception will be raised. + # + # By default it is not raised. See Mustache.raise_on_context_miss. + class ContextMiss < RuntimeError; end + end + end +end diff --git a/lib/braintrust/vendor/mustache/enumerable.rb b/lib/braintrust/vendor/mustache/enumerable.rb new file mode 100644 index 0000000..422076d --- /dev/null +++ b/lib/braintrust/vendor/mustache/enumerable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +module Braintrust + module Vendor + class Mustache + Enumerable = Module.new + end + end +end diff --git a/lib/braintrust/vendor/mustache/generator.rb b/lib/braintrust/vendor/mustache/generator.rb new file mode 100644 index 0000000..c2a6b9b --- /dev/null +++ b/lib/braintrust/vendor/mustache/generator.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +module Braintrust + module Vendor + class Mustache + # The Generator is in charge of taking an array of Mustache tokens, + # usually assembled by the Parser, and generating an interpolatable + # Ruby string. This string is considered the "compiled" template + # because at that point we're relying on Ruby to do the parsing and + # run our code. + # + # For example, let's take this template: + # + # Hi {{thing}}! + # + # If we run this through the Parser we'll get these tokens: + # + # [:multi, + # [:static, "Hi "], + # [:mustache, :etag, "thing"], + # [:static, "!\n"]] + # + # Now let's hand that to the Generator: + # + # >> puts Braintrust::Vendor::Mustache::Generator.new.compile(tokens) + # "Hi #{ctx.escape(ctx[:thing])}!\n" + class Generator + # Options can be used to manipulate the resulting ruby code string behavior. + def initialize(options = {}) + @options = options + @option_static_lambdas = options[:static_lambdas] == true + end + + # Given an array of tokens, returns an interpolatable Ruby string. + def compile(exp) + "\"#{compile!(exp)}\"" + end + + private + + # Given an array of tokens, converts them into Ruby code. In + # particular there are three types of expressions we are concerned + # with: + # + # :multi + # Mixed bag of :static, :mustache, and whatever. + # + # :static + # Normal HTML, the stuff outside of {{mustaches}}. + # + # :mustache + # Any Mustache tag, from sections to partials. + def compile!(exp) + case exp.first + when :multi + exp[1..-1].reduce(+"") { |sum, e| sum << compile!(e) } + when :static + str(exp[1]) + when :mustache + send(:"on_#{exp[1]}", *exp[2..-1]) + else + raise "Unhandled exp: #{exp.first}" + end + end + + # Callback fired when the compiler finds a section token. We're + # passed the section name and the array of tokens. + def on_section(name, offset, content, raw, delims) + # Convert the tokenized content of this section into a Ruby + # string we can use. + code = compile(content) + + # Lambda handling - default handling is to dynamically interpret + # the returned lambda result as mustache source + proc_handling = if @option_static_lambdas + <<-compiled + v.call(lambda {|v| #{code}}.call(v)).to_s + compiled + else + <<-compiled + t = Braintrust::Vendor::Mustache::Template.new(v.call(#{raw.inspect}).to_s) + def t.tokens(src=@source) + p = Braintrust::Vendor::Mustache::Parser.new + p.otag, p.ctag = #{delims.inspect} + p.compile(src) + end + t.render(ctx.dup) + compiled + end + + # Compile the Ruby for this section now that we know what's + # inside the section. + ev(<<-compiled) + case v = #{compile!(name)} + when NilClass, FalseClass + when TrueClass + #{code} + when Proc + #{proc_handling} + when Array, Enumerator, Braintrust::Vendor::Mustache::Enumerable + v.map { |_| ctx.push(_); r = #{code}; ctx.pop; r }.join + else + ctx.push(v); r = #{code}; ctx.pop; r + end + compiled + end + + # Fired when we find an inverted section. Just like `on_section`, + # we're passed the inverted section name and the array of tokens. + def on_inverted_section(name, offset, content, raw, delims) + # Convert the tokenized content of this section into a Ruby + # string we can use. + code = compile(content) + + # Compile the Ruby for this inverted section now that we know + # what's inside. + ev(<<-compiled) + v = #{compile!(name)} + if v.nil? || v == false || v.respond_to?(:empty?) && v.empty? + #{code} + end + compiled + end + + # Fired when the compiler finds a partial. We want to return code + # which calls a partial at runtime instead of expanding and + # including the partial's body to allow for recursive partials. + def on_partial(name, offset, indentation) + ev("ctx.partial(#{name.to_sym.inspect}, #{indentation.inspect})") + end + + # An unescaped tag. + def on_utag(name, offset) + ev(<<-compiled) + v = #{compile!(name)} + if v.is_a?(Proc) + v = #{@option_static_lambdas ? "v.call" : "Braintrust::Vendor::Mustache::Template.new(v.call.to_s).render(ctx.dup)"} + end + v.to_s + compiled + end + + # An escaped tag. + def on_etag(name, offset) + ev(<<-compiled) + v = #{compile!(name)} + if v.is_a?(Proc) + v = #{@option_static_lambdas ? "v.call" : "Braintrust::Vendor::Mustache::Template.new(v.call.to_s).render(ctx.dup)"} + end + ctx.escape(v) + compiled + end + + def on_fetch(names) + return "ctx.current" if names.empty? + + names = names.map { |n| n.to_sym } + + initial, *rest = names + if rest.any? + <<-compiled + #{rest.inspect}.reduce(ctx[#{initial.inspect}]) { |value, key| value && ctx.find(value, key) } + compiled + else + <<-compiled + ctx[#{initial.inspect}] + compiled + end + end + + # An interpolation-friendly version of a string, for use within a + # Ruby string. + def ev(s) + "#\{#{s}}" + end + + def str(s) + s.inspect[1..-2] + end + end + end + end +end diff --git a/lib/braintrust/vendor/mustache/mustache.rb b/lib/braintrust/vendor/mustache/mustache.rb new file mode 100644 index 0000000..7624589 --- /dev/null +++ b/lib/braintrust/vendor/mustache/mustache.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: +# - Namespaced under Braintrust::Vendor +# - Disabled HTML escaping (LLM prompts don't need HTML escaping) + +require_relative "enumerable" +require_relative "template" +require_relative "context" +require_relative "settings" +require_relative "utils" + +module Braintrust + module Vendor + # Mustache is the base class from which your Mustache subclasses + # should inherit (though it can be used on its own). + # + # The typical Mustache workflow is as follows: + # + # * Create a Mustache subclass: class Stats < Mustache + # * Create a template: stats.mustache + # * Instantiate an instance: view = Stats.new + # * Render that instance: view.render + # + # You can skip the instantiation by calling `Stats.render` directly. + class Mustache + # Initialize a new Mustache instance. + # + # @param [Hash] options An options hash + # @option options [String] template_path + # @option options [String] template_extension + # @option options [String] template_file + # @option options [String] template + # @option options [String] view_namespace + # @option options [String] view_path + def initialize(options = {}) + @options = options + + initialize_settings + end + + # Instantiates an instance of this class and calls `render` with + # the passed args. + # + # @return A rendered String version of a template. + def self.render(*args) + new.render(*args) + end + + # Parses our fancy pants template file and returns normal file with + # all special {{tags}} and {{#sections}}replaced{{/sections}}. + # + # @example Render view + # @view.render("Hi {{thing}}!", :thing => :world) + # + # @example Set view template and then render + # View.template = "Hi {{thing}}!" + # @view = View.new + # @view.render(:thing => :world) + # + # @param [String,Hash] data A String template or a Hash context. + # If a Hash is given, we'll try to figure + # out the template from the class. + # @param [Hash] ctx A Hash context if `data` is a String template. + # @return [String] Returns a rendered version of a template. + def render(data = template, ctx = {}) + case data + when Hash + ctx = data + when Symbol + self.template_name = data + end + + tpl = case data + when Hash + templateify(template) + when Symbol + templateify(template) + else + templateify(data) + end + + return tpl.render(context) if ctx == {} + + begin + context.push(ctx) + tpl.render(context) + ensure + context.pop + end + end + + # Context accessors. + # + # @example Context accessors + # view = Mustache.new + # view[:name] = "Jon" + # view.template = "Hi, {{name}}!" + # view.render # => "Hi, Jon!" + def [](key) + context[key.to_sym] + end + + def []=(key, value) + context[key.to_sym] = value + end + + # A helper method which gives access to the context at a given time. + # Kind of a hack for now, but useful when you're in an iterating section + # and want access to the hash currently being iterated over. + def context + @context ||= Context.new(self) + end + + # Given a file name and an optional context, attempts to load and + # render the file as a template. + def self.render_file(name, context = {}) + render(partial(name), context) + end + + # Given a file name and an optional context, attempts to load and + # render the file as a template. + def render_file(name, context = {}) + self.class.render_file(name, context) + end + + # Given a name, attempts to read a file and return the contents as a + # string. The file is not rendered, so it might contain + # {{mustaches}}. + # + # Call `render` if you need to process it. + def self.partial(name) + new.partial(name) + end + + # Override this in your subclass if you want to do fun things like + # reading templates from a database. It will be rendered by the + # context, so all you need to do is return a string. + def partial(name) + path = "#{template_path}/#{name}.#{template_extension}" + + begin + File.read(path) + rescue + raise if raise_on_context_miss? + "" + end + end + + # BRAINTRUST MODIFICATION: No HTML escaping for LLM prompts. + # Original mustache uses CGI.escapeHTML which would turn + # characters like < > & " into HTML entities. + # For LLM prompts, we want the raw text without escaping. + # + # @param [Object] value Value to escape. + # @return [String] Unescaped content (just converted to string). + def escape(value) + value.to_s + end + + # @deprecated Use {#escape} instead. + # Kept for compatibility but also does no escaping. + def escapeHTML(str) + str.to_s + end + + # Has this instance or its class already compiled a template? + def compiled? + (@template && @template.is_a?(Template)) || self.class.compiled? + end + + private + + # When given a symbol or string representing a class, will try to produce an + # appropriate view class. + # + # @example + # Mustache.view_namespace = Hurl::Views + # Mustache.view_class(:Partial) # => Hurl::Views::Partial + def self.view_class(name) + name = classify(name.to_s) + + # Emptiness begets emptiness. + return Mustache if name.to_s.empty? + + name = "#{view_namespace}::#{name}" + const = rescued_const_get(name) + + return const if const + + const_from_file(name) + end + + def self.rescued_const_get(name) + const_get(name, true) || Mustache + rescue NameError + nil + end + + def self.const_from_file(name) + file_name = underscore(name) + file_path = "#{view_path}/#{file_name}.rb" + + return Mustache unless File.exist?(file_path) + + require file_path.chomp(".rb") + rescued_const_get(name) + end + + # Has this template already been compiled? Compilation is somewhat + # expensive so it may be useful to check this before attempting it. + def self.compiled? + @template.is_a? Template + end + + # template_partial => TemplatePartial + # template/partial => Template::Partial + def self.classify(underscored) + Mustache::Utils::String.new(underscored).classify + end + + # TemplatePartial => template_partial + # Template::Partial => template/partial + # Takes a string but defaults to using the current class' name. + def self.underscore(classified = name) + classified = superclass.name if classified.to_s.empty? + + Mustache::Utils::String.new(classified).underscore(view_namespace) + end + + # @param [Template,String] obj Turns `obj` into a template + # @param [Hash] options Options for template creation + def self.templateify(obj, options = {}) + obj.is_a?(Template) ? obj : Template.new(obj, options) + end + + def templateify(obj) + opts = {partial_resolver: method(:partial)} + opts.merge!(@options) if @options.is_a?(Hash) + self.class.templateify(obj, opts) + end + + # Return the value of the configuration setting on the superclass, or return + # the default. + # + # @param [Symbol] attr_name Name of the attribute. It should match + # the instance variable. + # @param [Object] default Default value to use if the superclass does + # not respond. + # + # @return Inherited or default configuration setting. + def self.inheritable_config_for(attr_name, default) + superclass.respond_to?(attr_name) ? superclass.send(attr_name) : default + end + end + end +end diff --git a/lib/braintrust/vendor/mustache/parser.rb b/lib/braintrust/vendor/mustache/parser.rb new file mode 100644 index 0000000..f46f3d8 --- /dev/null +++ b/lib/braintrust/vendor/mustache/parser.rb @@ -0,0 +1,364 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +require "strscan" + +module Braintrust + module Vendor + class Mustache + # The Parser is responsible for taking a string template and + # converting it into an array of tokens and, really, expressions. It + # raises SyntaxError if there is anything it doesn't understand and + # knows which sigil corresponds to which tag type. + # + # For example, given this template: + # + # Hi {{thing}}! + # + # Run through the Parser we'll get these tokens: + # + # [:multi, + # [:static, "Hi "], + # [:mustache, :etag, "thing"], + # [:static, "!\n"]] + class Parser + # A SyntaxError is raised when the Parser comes across unclosed + # tags, sections, illegal content in tags, or anything of that + # sort. + class SyntaxError < StandardError + def initialize(message, position) + @message = message + @lineno, @column, @line, _ = position + @stripped_line = @line.strip + @stripped_column = @column - (@line.size - @line.lstrip.size) + end + + def to_s + <<-EOF +#{@message} + Line #{@lineno} + #{@stripped_line} + #{" " * @stripped_column}^ +EOF + end + end + + # The sigil types which are valid after an opening `{{` + VALID_TYPES = ["#", "^", "/", "=", "!", "<", ">", "&", "{"].map(&:freeze) + + def self.valid_types + @valid_types ||= Regexp.new(VALID_TYPES.map { |t| Regexp.escape(t) }.join("|")) + end + + # Add a supported sigil type (with optional aliases) to the Parser. + # + # Requires a block, which will be sent the following parameters: + # + # * content - The raw content of the tag + # * fetch- A mustache context fetch expression for the content + # * padding - Indentation whitespace from the currently-parsed line + # * pre_match_position - Location of the scanner before a match was made + # + # The provided block will be evaluated against the current instance of + # Parser, and may append to the Parser's @result as needed. + def self.add_type(*types, &block) + types = types.map(&:to_s) + type, *aliases = types + method_name = :"scan_tag_#{type}" + define_method(method_name, &block) + aliases.each { |a| alias_method :"scan_tag_#{a}", method_name } + types.each { |t| VALID_TYPES << t unless VALID_TYPES.include?(t) } + @valid_types = nil + end + + # After these types of tags, all whitespace until the end of the line will + # be skipped if they are the first (and only) non-whitespace content on + # the line. + SKIP_WHITESPACE = ["#", "^", "/", "<", ">", "=", "!"].map(&:freeze) + + # The content allowed in a tag name. + ALLOWED_CONTENT = /(\w|[?!\/.=-])*/ + + # These types of tags allow any content, + # the rest only allow ALLOWED_CONTENT. + ANY_CONTENT = ["!", "="].map(&:freeze) + + attr_reader :otag, :ctag + + # Accepts an options hash which does nothing but may be used in + # the future. + def initialize(options = {}) + @options = options + @option_inline_partials_at_compile_time = options[:inline_partials_at_compile_time] + if @option_inline_partials_at_compile_time + @partial_resolver = options[:partial_resolver] + raise ArgumentError.new "Missing or invalid partial_resolver" unless @partial_resolver.respond_to? :call + end + + # Initialize default tags + self.otag ||= "{{" + self.ctag ||= "}}" + end + + # The opening tag delimiter. This may be changed at runtime. + def otag=(value) + regex = regexp value + @otag_regex = /([ \t]*)?#{regex}/ + @otag_not_regex = /(^[ \t]*)?#{regex}/ + @otag = value + end + + # The closing tag delimiter. This too may be changed at runtime. + def ctag=(value) + @ctag_regex = regexp value + @ctag = value + end + + # Given a string template, returns an array of tokens. + def compile(template) + @encoding = nil + + if template.respond_to?(:encoding) + @encoding = template.encoding + template = template.dup.force_encoding("BINARY") + end + + # Keeps information about opened sections. + @sections = [] + @result = [:multi] + @scanner = StringScanner.new(template) + + # Scan until the end of the template. + until @scanner.eos? + scan_tags || scan_text + end + + unless @sections.empty? + # We have parsed the whole file, but there's still opened sections. + type, pos, _ = @sections.pop + error "Unclosed section #{type.inspect}", pos + end + + @result + end + + private + + def content_tags(type, current_ctag_regex) + if ANY_CONTENT.include?(type) + r = /\s*#{regexp(type)}?#{current_ctag_regex}/ + scan_until_exclusive(r) + else + @scanner.scan(ALLOWED_CONTENT) + end + end + + def dispatch_based_on_type(type, content, fetch, padding, pre_match_position) + send(:"scan_tag_#{type}", content, fetch, padding, pre_match_position) + end + + def find_closing_tag(scanner, current_ctag_regex) + error "Unclosed tag" unless scanner.scan(current_ctag_regex) + end + + # Find {{mustaches}} and add them to the @result array. + def scan_tags + # Scan until we hit an opening delimiter. + start_of_line = @scanner.beginning_of_line? + pre_match_position = @scanner.pos + last_index = @result.length + + return unless @scanner.scan @otag_regex + padding = @scanner[1] || "" + + # Don't touch the preceding whitespace unless we're matching the start + # of a new line. + unless start_of_line + @result << [:static, padding] unless padding.empty? + pre_match_position += padding.length + padding = "" + end + + # Since {{= rewrites ctag, we store the ctag which should be used + # when parsing this specific tag. + current_ctag_regex = @ctag_regex + type = @scanner.scan(self.class.valid_types) + @scanner.skip(/\s*/) + + # ANY_CONTENT tags allow any character inside of them, while + # other tags (such as variables) are more strict. + content = content_tags(type, current_ctag_regex) + + # We found {{ but we can't figure out what's going on inside. + error "Illegal content in tag" if content.empty? + + fetch = [:mustache, :fetch, content.split(".")] + prev = @result + + dispatch_based_on_type(type, content, fetch, padding, pre_match_position) + + # The closing } in unescaped tags is just a hack for + # aesthetics. + type = "}" if type == "{" + + # Skip whitespace and any balancing sigils after the content + # inside this tag. + @scanner.skip(/\s+/) + @scanner.skip(regexp(type)) if type + + find_closing_tag(@scanner, current_ctag_regex) + + # If this tag was the only non-whitespace content on this line, strip + # the remaining whitespace. If not, but we've been hanging on to padding + # from the beginning of the line, re-insert the padding as static text. + if start_of_line && !@scanner.eos? + if @scanner.peek(2) =~ /\r?\n/ && SKIP_WHITESPACE.include?(type) + @scanner.skip(/\r?\n/) + else + prev.insert(last_index, [:static, padding]) unless padding.empty? + end + end + + # Store off the current scanner position now that we've closed the tag + # and consumed any irrelevant whitespace. + @sections.last[1] << @scanner.pos unless @sections.empty? + + return unless @result == [:multi] + end + + # Try to find static text, e.g. raw HTML with no {{mustaches}}. + def scan_text + text = scan_until_exclusive @otag_not_regex + + if text.nil? + # Couldn't find any otag, which means the rest is just static text. + text = @scanner.rest + # Mark as done. + @scanner.terminate + end + + text.force_encoding(@encoding) if @encoding + + @result << [:static, text] unless text.empty? + end + + # Scans the string until the pattern is matched. Returns the substring + # *excluding* the end of the match, advancing the scan pointer to that + # location. If there is no match, nil is returned. + def scan_until_exclusive(regexp) + pos = @scanner.pos + if @scanner.scan_until(regexp) + @scanner.pos -= @scanner.matched.size + @scanner.pre_match[pos..-1] + end + end + + def offset + position[0, 2] + end + + # Returns [lineno, column, line] + def position + # The rest of the current line + rest = @scanner.check_until(/\n|\Z/).to_s.chomp + + # What we have parsed so far + parsed = @scanner.string[0...@scanner.pos] + + lines = parsed.split("\n") + + [lines.size, lines.last.size - 1, lines.last + rest] + end + + # Used to quickly convert a string into a regular expression + # usable by the string scanner. + def regexp(thing) + Regexp.new Regexp.escape(thing) if thing + end + + # Raises a SyntaxError. The message should be the name of the + # error - other details such as line number and position are + # handled for you. + def error(message, pos = position) + raise SyntaxError.new(message, pos) + end + + # + # Scan tags + # + # These methods are called in `scan_tags`. Because they contain nonstandard + # characters in their method names, they are aliased to + # better named methods. + # + + # This function handles the cases where the scanned tag does not have + # a type. + def scan_tag_(content, fetch, padding, pre_match_position) + @result << [:mustache, :etag, fetch, offset] + end + + def scan_tag_block(content, fetch, padding, pre_match_position) + block = [:multi] + @result << [:mustache, :section, fetch, offset, block] + @sections << [content, position, @result] + @result = block + end + alias_method :'scan_tag_#', :scan_tag_block + + def scan_tag_inverted(content, fetch, padding, pre_match_position) + block = [:multi] + @result << [:mustache, :inverted_section, fetch, offset, block] + @sections << [content, position, @result] + @result = block + end + alias_method :'scan_tag_^', :scan_tag_inverted + + def scan_tag_close(content, fetch, padding, pre_match_position) + section, pos, result = @sections.pop + if section.nil? + error "Closing unopened #{content.inspect}" + end + + raw = @scanner.pre_match[pos[3]...pre_match_position] + padding + (@result = result).last << raw << [otag, ctag] + + if section != content + error "Unclosed section #{section.inspect}", pos + end + end + alias_method :'scan_tag_/', :scan_tag_close + + def scan_tag_comment(content, fetch, padding, pre_match_position) + end + alias_method :'scan_tag_!', :scan_tag_comment + + def scan_tag_delimiter(content, fetch, padding, pre_match_position) + self.otag, self.ctag = content.split(" ", 2) + end + alias_method :'scan_tag_=', :scan_tag_delimiter + + def scan_tag_open_partial(content, fetch, padding, pre_match_position) + @result << if @option_inline_partials_at_compile_time + partial = @partial_resolver.call content + partial.gsub!(/^/, padding) unless padding.empty? + self.class.new(@options).compile partial + else + [:mustache, :partial, content, offset, padding] + end + end + alias_method :'scan_tag_<', :scan_tag_open_partial + alias_method :'scan_tag_>', :scan_tag_open_partial + + def scan_tag_unescaped(content, fetch, padding, pre_match_position) + @result << [:mustache, :utag, fetch, offset] + end + alias_method :'scan_tag_{', :scan_tag_unescaped + alias_method :'scan_tag_&', :scan_tag_unescaped + end + end + end +end diff --git a/lib/braintrust/vendor/mustache/settings.rb b/lib/braintrust/vendor/mustache/settings.rb new file mode 100644 index 0000000..f9225ef --- /dev/null +++ b/lib/braintrust/vendor/mustache/settings.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +# Settings which can be configured for all view classes, a single +# view class, or a single Mustache instance. +module Braintrust + module Vendor + class Mustache + def initialize_settings + @template = nil + @template_path = nil + @template_extension = nil + @template_name = nil + @template_file = nil + @raise_on_context_miss = nil + end + + def self.initialize_settings + @template = nil + @template_path = nil + @template_extension = nil + @template_name = nil + @template_file = nil + @raise_on_context_miss = nil + end + + initialize_settings + + def self.inherited(subclass) + subclass.initialize_settings + end + + # + # Template Path + # + + # The template path informs your Mustache view where to look for its + # corresponding template. By default it's the current directory (".") + # + # A class named Stat with a template_path of "app/templates" will look + # for "app/templates/stat.mustache" + + def self.template_path + @template_path ||= inheritable_config_for :template_path, "." + end + + def self.template_path=(path) + @template_path = File.expand_path(path) + @template = nil + end + + def template_path + @template_path ||= self.class.template_path + end + alias_method :path, :template_path + + def template_path=(path) + @template_path = File.expand_path(path) + @template = nil + end + + # Alias for `template_path` + def self.path + template_path + end + + # Alias for `template_path` + def self.path=(path) + self.template_path = path + end + + # + # Template Extension + # + + # A Mustache template's default extension is 'mustache', but this can be changed. + + def self.template_extension + @template_extension ||= inheritable_config_for :template_extension, "mustache" + end + + def self.template_extension=(template_extension) + @template_extension = template_extension + @template = nil + end + + def template_extension + @template_extension ||= self.class.template_extension + end + + def template_extension=(template_extension) + @template_extension = template_extension + @template = nil + end + + # + # Template Name + # + + # The template name is the Mustache template file without any + # extension or other information. Defaults to `class_name`. + # + # You may want to change this if your class is named Stat but you want + # to re-use another template. + # + # class Stat + # self.template_name = "graphs" # use graphs.mustache + # end + + def self.template_name + @template_name || underscore + end + + def self.template_name=(template_name) + @template_name = template_name + @template = nil + end + + def template_name + @template_name ||= self.class.template_name + end + + def template_name=(template_name) + @template_name = template_name + @template = nil + end + + # + # Template File + # + + # The template file is the absolute path of the file Mustache will + # use as its template. By default it's ./class_name.mustache + + def self.template_file + @template_file || "#{path}/#{template_name}.#{template_extension}" + end + + def self.template_file=(template_file) + @template_file = template_file + @template = nil + end + + # The template file is the absolute path of the file Mustache will + # use as its template. By default it's ./class_name.mustache + def template_file + @template_file || "#{path}/#{template_name}.#{template_extension}" + end + + def template_file=(template_file) + @template_file = template_file + @template = nil + end + + # + # Template + # + + # The template is the actual string Mustache uses as its template. + # There is a bit of magic here: what we get back is actually a + # Mustache::Template object, but you can still safely use `template=` + # with a string. + + def self.template + @template ||= templateify(File.read(template_file)) + end + + def self.template=(template) + @template = templateify(template) + end + + # The template can be set at the instance level. + def template + return @template if @template + + # If they sent any instance-level options use that instead of the class's. + if @template_path || @template_extension || @template_name || @template_file + @template = templateify(File.read(template_file)) + else + @template = self.class.template + end + end + + def template=(template) + @template = templateify(template) + end + + # + # Raise on context miss + # + + # Should an exception be raised when we cannot find a corresponding method + # or key in the current context? By default this is false to emulate ctemplate's + # behavior, but it may be useful to enable when debugging or developing. + # + # If set to true and there is a context miss, `Mustache::ContextMiss` will + # be raised. + + def self.raise_on_context_miss? + @raise_on_context_miss + end + + def self.raise_on_context_miss=(boolean) + @raise_on_context_miss = boolean + end + + # Instance level version of `Mustache.raise_on_context_miss?` + def raise_on_context_miss? + self.class.raise_on_context_miss? || @raise_on_context_miss + end + + def raise_on_context_miss=(boolean) + @raise_on_context_miss = boolean + end + + # + # View Namespace + # + + # The constant under which Mustache will look for views when autoloading. + # By default the view namespace is `Object`, but it might be nice to set + # it to something like `Hurl::Views` if your app's main namespace is `Hurl`. + + def self.view_namespace + @view_namespace ||= inheritable_config_for(:view_namespace, Object) + end + + def self.view_namespace=(namespace) + @view_namespace = namespace + end + + # + # View Path + # + + # Mustache searches the view path for .rb files to require when asked to find a + # view class. Defaults to "." + + def self.view_path + @view_path ||= inheritable_config_for(:view_path, ".") + end + + def self.view_path=(path) + @view_path = path + end + end + end +end diff --git a/lib/braintrust/vendor/mustache/template.rb b/lib/braintrust/vendor/mustache/template.rb new file mode 100644 index 0000000..cd8c093 --- /dev/null +++ b/lib/braintrust/vendor/mustache/template.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +require "cgi" + +require_relative "parser" +require_relative "generator" + +module Braintrust + module Vendor + class Mustache + # A Template represents a Mustache template. It compiles and caches + # a raw string template into something usable. + # + # The idea is this: when handed a Mustache template, convert it into + # a Ruby string by transforming Mustache tags into interpolated + # Ruby. + # + # You shouldn't use this class directly, instead: + # + # >> Braintrust::Vendor::Mustache.render(template, hash) + class Template + attr_reader :source + + # Expects a Mustache template as a string along with a template + # path, which it uses to find partials. Options may be passed. + def initialize(source, options = {}) + @source = source + @options = options + end + + # Renders the `@source` Mustache template using the given + # `context`, which should be a simple hash keyed with symbols. + # + # The first time a template is rendered, this method is overriden + # and from then on it is "compiled". Subsequent calls will skip + # the compilation step and run the Ruby version of the template + # directly. + def render(context) + # Compile our Mustache template into a Ruby string + compiled = "def render(ctx) #{compile} end" + + # Here we rewrite ourself with the interpolated Ruby version of + # our Mustache template so subsequent calls are very fast and + # can skip the compilation stage. + instance_eval(compiled, __FILE__, __LINE__ - 1) + + # Call the newly rewritten version of #render + render(context) + end + + # Does the dirty work of transforming a Mustache template into an + # interpolation-friendly Ruby string. + def compile(src = @source) + Generator.new(@options).compile(tokens(src)) + end + alias_method :to_s, :compile + + # Returns an array of tokens for a given template. + # + # @return [Array] Array of tokens. + # + def tokens(src = @source) + Parser.new(@options).compile(src) + end + + # Returns an array of tags. + # + # Tags that belong to sections will be of the form `section1.tag`. + # + # @return [Array] Returns an array of tags. + # + def tags + Template.recursor(tokens, []) do |token, section| + if [:etag, :utag].include?(token[1]) + [new_token = nil, new_section = nil, result = ((section + [token[2][2][0]]).join(".")), stop = true] + elsif [:section, :inverted_section].include?(token[1]) + [new_token = token[4], new_section = (section + [token[2][2][0]]), result = nil, stop = false] + else + [new_token = token, new_section = section, result = nil, stop = false] + end + end.flatten.reject(&:nil?).uniq + end + + # Returns an array of sections. + # + # Sections that belong to other sections will be of the form `section1.childsection` + # + # @return [Array] Returns an array of section. + # + def sections + Template.recursor(tokens, []) do |token, section| + if [:section, :inverted_section].include?(token[1]) + new_section = (section + [token[2][2][0]]) + [new_token = token[4], new_section, result = new_section.join("."), stop = false] + else + [new_token = token, new_section = section, result = nil, stop = false] + end + end.flatten.reject(&:nil?).uniq + end + + # Returns an array of partials. + # + # Partials that belong to sections are included, but the section name is not preserved + # + # @return [Array] Returns an array of partials. + # + def partials + Template.recursor(tokens, []) do |token, section| + if token[1] == :partial + [new_token = token, new_section = section, result = token[2], stop = true] + else + [new_token = token, new_section = section, result = nil, stop = false] + end + end.flatten.reject(&:nil?).uniq + end + + # Simple recursive iterator for tokens + def self.recursor(toks, section, &block) + toks.map do |token| + next unless token.is_a? Array + + if token.first == :mustache + new_token, new_section, result, stop = yield(token, section) + [result] + (stop ? [] : recursor(new_token, new_section, &block)) + else + recursor(token, section, &block) + end + end + end + end + end + end +end diff --git a/lib/braintrust/vendor/mustache/utils.rb b/lib/braintrust/vendor/mustache/utils.rb new file mode 100644 index 0000000..4975ceb --- /dev/null +++ b/lib/braintrust/vendor/mustache/utils.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Vendored from mustache gem v1.1.1 +# https://github.com/mustache/mustache +# License: MIT +# Modifications: Namespaced under Braintrust::Vendor + +module Braintrust + module Vendor + class Mustache + module Utils + class String + def initialize(string) + @string = string + end + + def classify + @string.split("/").map do |namespace| + namespace.split(/[-_]/).map do |part| + part[0] = part.chars.first.upcase + part + end.join + end.join("::") + end + + def underscore(view_namespace) + @string + .dup + .split("#{view_namespace}::") + .last + .split("::") + .map do |part| + part[0] = part[0].downcase + part.gsub(/[A-Z]/) { |s| "_" << s.downcase } + end + .join("/") + end + end + end + end + end +end diff --git a/test/braintrust/api/functions_test.rb b/test/braintrust/api/functions_test.rb index 753c1d2..5afef0a 100644 --- a/test/braintrust/api/functions_test.rb +++ b/test/braintrust/api/functions_test.rb @@ -378,4 +378,93 @@ def test_helper_validates_prompt_data_has_prompt_key end end end + + def test_functions_get_by_id + VCR.use_cassette("functions/get") do + api = get_test_api + # This test verifies that we can get the full function data by ID, + # including prompt_data which is needed for Prompt.load() + function_slug = "test-ruby-sdk-get-func" + + # Create a function with prompt_data + create_response = api.functions.create( + project_name: @project_name, + slug: function_slug, + function_data: {type: "prompt"}, + prompt_data: { + prompt: { + type: "chat", + messages: [ + {role: "system", content: "You are a helpful assistant."}, + {role: "user", content: "Hello {{name}}"} + ] + }, + options: { + model: "gpt-4o-mini", + params: {temperature: 0.7} + } + } + ) + function_id = create_response["id"] + + # Get the full function data + result = api.functions.get(id: function_id) + + assert_instance_of Hash, result + assert_equal function_id, result["id"] + assert_equal function_slug, result["slug"] + + # Verify prompt_data is included + assert result.key?("prompt_data") + prompt_data = result["prompt_data"] + assert prompt_data.key?("prompt") + assert prompt_data["prompt"].key?("messages") + assert_equal 2, prompt_data["prompt"]["messages"].length + + # Verify options are included + assert prompt_data.key?("options") + assert_equal "gpt-4o-mini", prompt_data["options"]["model"] + + # Clean up + api.functions.delete(id: function_id) + end + end + + def test_functions_get_with_version + VCR.use_cassette("functions/get_with_version") do + api = get_test_api + function_slug = "test-ruby-sdk-get-version" + + # Create a function and capture its version (_xact_id) + create_response = api.functions.create( + project_name: @project_name, + slug: function_slug, + function_data: {type: "prompt"}, + prompt_data: { + prompt: { + type: "chat", + messages: [ + {role: "user", content: "Version test message"} + ] + }, + options: {model: "gpt-4o-mini"} + } + ) + function_id = create_response["id"] + version_id = create_response["_xact_id"] + + assert version_id, "Expected _xact_id in create response" + + # Get the function with explicit version + result = api.functions.get(id: function_id, version: version_id) + + assert_instance_of Hash, result + assert_equal function_id, result["id"] + assert_equal function_slug, result["slug"] + assert result.key?("prompt_data") + + # Clean up + api.functions.delete(id: function_id) + end + end end diff --git a/test/braintrust/internal/template_test.rb b/test/braintrust/internal/template_test.rb new file mode 100644 index 0000000..05981a6 --- /dev/null +++ b/test/braintrust/internal/template_test.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require "test_helper" +require "braintrust/internal/template" + +class Braintrust::Internal::TemplateTest < Minitest::Test + # render - mustache format + + def test_render_substitutes_variables + result = Braintrust::Internal::Template.render( + "Hello {{name}}, welcome to {{place}}!", + {"name" => "Alice", "place" => "Wonderland"}, + format: "mustache" + ) + assert_equal "Hello Alice, welcome to Wonderland!", result + end + + def test_render_with_symbol_keys + result = Braintrust::Internal::Template.render( + "Hello {{name}}!", + {name: "Bob"}, + format: "mustache" + ) + assert_equal "Hello Bob!", result + end + + def test_render_with_nested_variables + result = Braintrust::Internal::Template.render( + "User: {{user.name}}, Email: {{user.email}}", + {"user" => {"name" => "Alice", "email" => "alice@example.com"}}, + format: "mustache" + ) + assert_equal "User: Alice, Email: alice@example.com", result + end + + def test_render_does_not_escape_html_characters + # LLM prompts should NOT have HTML escaping applied + result = Braintrust::Internal::Template.render( + "Code: {{code}}", + {"code" => ' & other < > stuff'}, + format: "mustache" + ) + assert_includes result, " & other < > stuff' + result = prompt.build(code: code_with_html) + + # Verify NO escaping occurred - raw characters preserved + assert_equal "Write code: #{code_with_html}", result[:messages][0][:content] + assert_includes result[:messages][0][:content], "