From 7de86e0e180b31f28b99411986f2e7315ee5f1f9 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Sun, 16 Aug 2026 23:50:46 -0700 Subject: [PATCH 1/2] feat(prompts): expose template variables --- lib/langfuse.rb | 1 + lib/langfuse/chat_prompt_client.rb | 17 ++++++++ lib/langfuse/prompt_variables.rb | 53 ++++++++++++++++++++++++ lib/langfuse/text_prompt_client.rb | 12 ++++++ spec/langfuse/chat_prompt_client_spec.rb | 24 +++++++++++ spec/langfuse/text_prompt_client_spec.rb | 27 ++++++++++++ 6 files changed, 134 insertions(+) create mode 100644 lib/langfuse/prompt_variables.rb diff --git a/lib/langfuse.rb b/lib/langfuse.rb index 70b6f53..a586ab0 100644 --- a/lib/langfuse.rb +++ b/lib/langfuse.rb @@ -82,6 +82,7 @@ class UnauthorizedError < ApiError; end require_relative "langfuse/score_value" require_relative "langfuse/score_client" require_relative "langfuse/prompt_renderer" +require_relative "langfuse/prompt_variables" require_relative "langfuse/text_prompt_client" require_relative "langfuse/chat_prompt_client" require_relative "langfuse/timestamp_parser" diff --git a/lib/langfuse/chat_prompt_client.rb b/lib/langfuse/chat_prompt_client.rb index b516171..23dffe8 100644 --- a/lib/langfuse/chat_prompt_client.rb +++ b/lib/langfuse/chat_prompt_client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "prompt_renderer" +require_relative "prompt_variables" module Langfuse # Chat prompt client for compiling chat prompts with variable substitution @@ -73,6 +74,22 @@ def type "chat" end + # Return the unique variables referenced by all message templates + # + # Section names are included because callers must provide their values. + # Message placeholder entries are not Mustache templates and are excluded. + # + # @return [Array] Referenced variable names in message and source order + # @raise [Mustache::Parser::SyntaxError] if a message contains invalid Mustache syntax + def variables + prompt.each_with_object([]) do |message, names| + normalized = symbolize_keys(message) + next if normalized[:type].to_s == PLACEHOLDER_TYPE + + names.concat(PromptVariables.extract(normalized[:content] || "")) + end.uniq + end + # Compile the chat prompt with variable substitution and message placeholders # # Returns an array of message hashes with roles and compiled content. diff --git a/lib/langfuse/prompt_variables.rb b/lib/langfuse/prompt_variables.rb new file mode 100644 index 0000000..4b5b3f7 --- /dev/null +++ b/lib/langfuse/prompt_variables.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "mustache" + +module Langfuse + # Extracts referenced variables from parsed Mustache templates. + # + # @api private + class PromptVariables + TAG_TYPES = %i[etag utag].freeze + SECTION_TYPES = %i[section inverted_section].freeze + + class << self + # @api private + def extract(template) + tokens = Mustache::Template.new(template).tokens + collect(tokens, []).reject(&:empty?).uniq + end + + private + + def collect(tokens, scope) + tokens.each_with_object([]) do |token, variables| + next unless token.is_a?(Array) + + variables.concat(token.first == :mustache ? from_tag(token, scope) : collect(token, scope)) + end + end + + def from_tag(token, scope) + return variable_path(token, scope) if TAG_TYPES.include?(token[1]) + return section_paths(token, scope) if SECTION_TYPES.include?(token[1]) + + [] + end + + def variable_path(token, scope) + path = scoped_path(token, scope) + path.empty? ? [] : [path.join(".")] + end + + def section_paths(token, scope) + section_path = scoped_path(token, scope) + [section_path.join("."), *collect(token[4], section_path)] + end + + def scoped_path(token, scope) + segments = token.dig(2, 2) + segments == ["."] ? scope : scope + segments + end + end + end +end diff --git a/lib/langfuse/text_prompt_client.rb b/lib/langfuse/text_prompt_client.rb index c3fd426..e9d1348 100644 --- a/lib/langfuse/text_prompt_client.rb +++ b/lib/langfuse/text_prompt_client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "prompt_renderer" +require_relative "prompt_variables" module Langfuse # Text prompt client for compiling text prompts with variable substitution @@ -71,6 +72,17 @@ def type "text" end + # Return the unique variables referenced by the prompt template + # + # Section names are included because callers must provide their values. + # Variables inside sections include the full section path. + # + # @return [Array] Referenced variable names in source order + # @raise [Mustache::Parser::SyntaxError] if the prompt contains invalid Mustache syntax + def variables + PromptVariables.extract(prompt) + end + # Compile the prompt with variable substitution # # @param kwargs [Hash] Variables to substitute in the template (as keyword arguments) diff --git a/spec/langfuse/chat_prompt_client_spec.rb b/spec/langfuse/chat_prompt_client_spec.rb index 9c8b46d..9b240f8 100644 --- a/spec/langfuse/chat_prompt_client_spec.rb +++ b/spec/langfuse/chat_prompt_client_spec.rb @@ -152,6 +152,30 @@ end end + describe "#variables" do + it "returns unique variables across message templates" do + data = prompt_data.merge( + "prompt" => [ + { "role" => "system", "content" => "Hello {{user.name}} and {{shared}}" }, + { "role" => "user", "content" => "{{shared}} {{#details}}{{topic}}{{/details}}" } + ] + ) + + expect(described_class.new(data).variables).to eq(%w[user.name shared details details.topic]) + end + + it "excludes message placeholders" do + data = prompt_data.merge( + "prompt" => [ + { "type" => "placeholder", "name" => "history" }, + { type: "message", role: "user", content: "Question: {{{question}}}" } + ] + ) + + expect(described_class.new(data).variables).to eq(["question"]) + end + end + describe "#compile" do let(:client) { described_class.new(prompt_data) } diff --git a/spec/langfuse/text_prompt_client_spec.rb b/spec/langfuse/text_prompt_client_spec.rb index 57c34fc..cd6f68c 100644 --- a/spec/langfuse/text_prompt_client_spec.rb +++ b/spec/langfuse/text_prompt_client_spec.rb @@ -136,6 +136,33 @@ end end + describe "#variables" do + it "returns unique parsed variables in source order" do + data = prompt_data.merge( + "prompt" => "{{name}} {{name}} {{profile.email}} {{{raw_html}}} {{& plain_html}} {{! ignored }}" + ) + + expect(described_class.new(data).variables).to eq(%w[name profile.email raw_html plain_html]) + end + + it "includes sections and scopes variables inside nested sections" do + data = prompt_data.merge( + "prompt" => "{{#account}}{{#owner}}{{profile.email}}{{/owner}}{{/account}}" \ + "{{^items}}{{message}}{{/items}}" + ) + + expect(described_class.new(data).variables).to eq( + %w[account account.owner account.owner.profile.email items items.message] + ) + end + + it "raises for invalid Mustache syntax" do + data = prompt_data.merge("prompt" => "{{#account}}{{name}}") + + expect { described_class.new(data).variables }.to raise_error(Mustache::Parser::SyntaxError) + end + end + describe "#compile" do let(:client) { described_class.new(prompt_data) } From c18a05b03d68bcd6205406c29efceda734a97f85 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Mon, 17 Aug 2026 07:40:13 -0700 Subject: [PATCH 2/2] fix(prompts): preserve inverted section scope --- lib/langfuse/prompt_variables.rb | 3 ++- spec/langfuse/text_prompt_client_spec.rb | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/langfuse/prompt_variables.rb b/lib/langfuse/prompt_variables.rb index 4b5b3f7..d0fe4a4 100644 --- a/lib/langfuse/prompt_variables.rb +++ b/lib/langfuse/prompt_variables.rb @@ -41,7 +41,8 @@ def variable_path(token, scope) def section_paths(token, scope) section_path = scoped_path(token, scope) - [section_path.join("."), *collect(token[4], section_path)] + body_scope = token[1] == :section ? section_path : scope + [section_path.join("."), *collect(token[4], body_scope)] end def scoped_path(token, scope) diff --git a/spec/langfuse/text_prompt_client_spec.rb b/spec/langfuse/text_prompt_client_spec.rb index cd6f68c..11f370b 100644 --- a/spec/langfuse/text_prompt_client_spec.rb +++ b/spec/langfuse/text_prompt_client_spec.rb @@ -152,7 +152,17 @@ ) expect(described_class.new(data).variables).to eq( - %w[account account.owner account.owner.profile.email items items.message] + %w[account account.owner account.owner.profile.email items message] + ) + end + + it "keeps inverted-section variables in the enclosing scope" do + data = prompt_data.merge( + "prompt" => "{{#account}}{{^owner}}{{fallback.name}}{{/owner}}{{/account}}" + ) + + expect(described_class.new(data).variables).to eq( + %w[account account.owner account.fallback.name] ) end