-
Notifications
You must be signed in to change notification settings - Fork 4
handle prompts, templating, etc. #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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": [] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 with tracing | ||
| Braintrust.init | ||
|
|
||
| # Wrap OpenAI client for tracing | ||
| openai = Braintrust::Trace::OpenAI.wrap(OpenAI::Client.new) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be updated to match the new instrumentation API. |
||
|
|
||
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to clarify, we want |
||
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if now when we load prompts we may need to check what templating language the prompt was using and fail to load the prompt if the templating language isn't available in a specific sdk. |
||
|
|
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "mustache" | ||
|
|
||
| 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 state [State, nil] Braintrust state (default: global) | ||
| # @return [Prompt] | ||
| def self.load(project:, slug:, version: nil, defaults: {}, state: nil) | ||
| state ||= Braintrust.current_state | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should a prompt define this dependency on state or should API? If API is meant to be a centrally used/reused public object perhaps we should be passing that in instead of |
||
| raise Error, "No state available - call Braintrust.init first" unless state | ||
|
|
||
| api = API.new(state: state) | ||
|
|
||
| # 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"]) | ||
|
|
||
| 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 = 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<Hash>] | ||
| def messages | ||
| prompt&.dig("messages") || [] | ||
| 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 | ||
|
|
||
| # 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(stringify_keys(variables_hash)).merge(stringify_keys(kwargs)) | ||
|
|
||
| # Render Mustache templates in messages | ||
| built_messages = messages.map do |msg| | ||
| { | ||
| role: msg["role"].to_sym, | ||
| content: render_template(msg["content"], vars, strict: strict) | ||
| } | ||
| end | ||
|
|
||
| # Build result with model and messages | ||
| result = { | ||
| model: model, | ||
| messages: built_messages | ||
| } | ||
|
|
||
| # 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 | ||
|
|
||
| private | ||
|
|
||
| # Render Mustache template with variables | ||
| def render_template(text, variables, strict:) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did we want to support the no template so people don't have to escape double braces in their prompts if they have any? Technically you can work around it in mustache by doing something like changing the delimiters by doing {{=<% %>=}} but might be nice to have no template option as well.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see anything specific to prompt in this function or variable manipulation methods. I think this should be extracted to |
||
| return text unless text.is_a?(String) | ||
|
|
||
| if strict | ||
| # Check for missing variables before rendering | ||
| missing = find_missing_variables(text, variables) | ||
| if missing.any? | ||
| raise Error, "Missing required variables: #{missing.join(", ")}" | ||
| end | ||
| end | ||
|
|
||
| Mustache.render(text, variables) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recently found out we have custom escape logic for mustache. It exists here in python and here in typescript. We should move that custom escaping over to make the experience consistent.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps naive thought here, but can this not be just done with a simple There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use mustache for templating in the UI when you create prompt. We recently also added the ability to have no template or nunjucks (a jinja like templating). This adds the same capability to the Ruby SDK that exists in the python and typescript SDK. If a user loads a prompt they created using mustache in the UI then it can be loaded from the SDK the same way. |
||
| end | ||
|
|
||
| # Find variables in template that are not provided | ||
| 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 the variables hash | ||
| 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 (handles both symbol and 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why a dependency on
mustache? We should try to avoid external dependencies wherever possible (invites conflicts with user apps which limits where it can be deployed.)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we use mustache for templates. what should i do instead?