Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/langfuse.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions lib/langfuse/chat_prompt_client.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String>] 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.
Expand Down
54 changes: 54 additions & 0 deletions lib/langfuse/prompt_variables.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
kxzk marked this conversation as resolved.

def scoped_path(token, scope)
segments = token.dig(2, 2)
segments == ["."] ? scope : scope + segments
end
end
end
end
12 changes: 12 additions & 0 deletions lib/langfuse/text_prompt_client.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String>] 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)
Expand Down
24 changes: 24 additions & 0 deletions spec/langfuse/chat_prompt_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
37 changes: 37 additions & 0 deletions spec/langfuse/text_prompt_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
Loading