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..d0fe4a4 --- /dev/null +++ b/lib/langfuse/prompt_variables.rb @@ -0,0 +1,54 @@ +# 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) + body_scope = token[1] == :section ? section_path : scope + [section_path.join("."), *collect(token[4], body_scope)] + 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..11f370b 100644 --- a/spec/langfuse/text_prompt_client_spec.rb +++ b/spec/langfuse/text_prompt_client_spec.rb @@ -136,6 +136,43 @@ 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 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 + + 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) }