langfuse-ruby 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,7 +64,7 @@ module Langfuse
64
64
  # Convert text prompt to LangChain PromptTemplate format
65
65
  {
66
66
  _type: 'prompt',
67
- input_variables: extract_variables(@prompt),
67
+ input_variables: TemplateCompiler.extract_variables(@prompt),
68
68
  template: @prompt
69
69
  }
70
70
  end
@@ -75,7 +75,7 @@ module Langfuse
75
75
  {
76
76
  _type: "#{message[:role]}_message",
77
77
  content: message[:content],
78
- input_variables: extract_variables(message[:content])
78
+ input_variables: TemplateCompiler.extract_variables(message[:content])
79
79
  }
80
80
  end
81
81
 
@@ -87,44 +87,11 @@ module Langfuse
87
87
  end
88
88
 
89
89
  def compile_text_prompt(variables)
90
- compiled = @prompt.dup
91
- variables.each do |key, value|
92
- compiled.gsub!("{{#{key}}}", value.to_s)
93
- compiled.gsub!("{#{key}}", value.to_s)
94
- end
95
- compiled
90
+ TemplateCompiler.compile(@prompt, variables)
96
91
  end
97
92
 
98
93
  def compile_chat_prompt(variables)
99
- @prompt.map do |message|
100
- compiled_content = message[:content].dup
101
- variables.each do |key, value|
102
- compiled_content.gsub!("{{#{key}}}", value.to_s)
103
- compiled_content.gsub!("{#{key}}", value.to_s)
104
- end
105
-
106
- {
107
- role: message[:role],
108
- content: compiled_content
109
- }
110
- end
111
- end
112
-
113
- def extract_variables(text)
114
- # Extract variables from template text (supports {{var}} and {var} formats)
115
- variables = []
116
-
117
- # Match {{variable}} format
118
- text.scan(/\{\{(\w+)\}\}/) do |match|
119
- variables << match[0]
120
- end
121
-
122
- # Match {variable} format
123
- text.scan(/\{(\w+)\}/) do |match|
124
- variables << match[0] unless variables.include?(match[0])
125
- end
126
-
127
- variables
94
+ TemplateCompiler.compile_messages(@prompt, variables)
128
95
  end
129
96
  end
130
97
 
@@ -137,33 +104,15 @@ module Langfuse
137
104
  end
138
105
 
139
106
  def format(variables = {})
140
- compiled = @template.dup
141
- variables.each do |key, value|
142
- compiled.gsub!("{{#{key}}}", value.to_s)
143
- compiled.gsub!("{#{key}}", value.to_s)
144
- end
145
- compiled
107
+ TemplateCompiler.compile(@template, variables)
146
108
  end
147
109
 
148
110
  def self.from_template(template)
149
- variables = extract_variables(template)
150
- new(template: template, input_variables: variables)
111
+ new(template: template, input_variables: extract_variables(template))
151
112
  end
152
113
 
153
114
  def self.extract_variables(text)
154
- variables = []
155
-
156
- # Match {{variable}} format
157
- text.scan(/\{\{(\w+)\}\}/) do |match|
158
- variables << match[0]
159
- end
160
-
161
- # Match {variable} format
162
- text.scan(/\{(\w+)\}/) do |match|
163
- variables << match[0] unless variables.include?(match[0])
164
- end
165
-
166
- variables
115
+ TemplateCompiler.extract_variables(text)
167
116
  end
168
117
  end
169
118
 
@@ -176,34 +125,11 @@ module Langfuse
176
125
  end
177
126
 
178
127
  def format(variables = {})
179
- @messages.map do |message|
180
- compiled_content = message[:content].dup
181
- variables.each do |key, value|
182
- compiled_content.gsub!("{{#{key}}}", value.to_s)
183
- compiled_content.gsub!("{#{key}}", value.to_s)
184
- end
185
-
186
- {
187
- role: message[:role],
188
- content: compiled_content
189
- }
190
- end
128
+ TemplateCompiler.compile_messages(@messages, variables)
191
129
  end
192
130
 
193
131
  def self.from_messages(messages)
194
- input_variables = []
195
-
196
- messages.each do |message|
197
- message[:content].scan(/\{\{(\w+)\}\}/) do |match|
198
- input_variables << match[0] unless input_variables.include?(match[0])
199
- end
200
-
201
- message[:content].scan(/\{(\w+)\}/) do |match|
202
- input_variables << match[0] unless input_variables.include?(match[0])
203
- end
204
- end
205
-
206
- new(messages: messages, input_variables: input_variables)
132
+ new(messages: messages, input_variables: TemplateCompiler.extract_message_variables(messages))
207
133
  end
208
134
  end
209
135
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Langfuse
6
+ # Bounded, thread-safe cache of Prompt objects, keyed by name/version/label.
7
+ #
8
+ # TTLs are measured on the monotonic clock so wall-clock jumps (NTP, DST) cannot
9
+ # extend or shorten an entry's lifetime. Entries keep insertion order and the
10
+ # oldest ones are evicted once the cache is full, so a long-running process with
11
+ # many prompt names cannot grow it without bound.
12
+ class PromptCache
13
+ DEFAULT_MAX_ENTRIES = 200
14
+
15
+ Entry = Struct.new(:prompt, :cached_at) do
16
+ def fresh?(ttl_seconds, now)
17
+ return false if ttl_seconds.nil?
18
+
19
+ now - cached_at < ttl_seconds
20
+ end
21
+ end
22
+
23
+ def initialize(max_entries: DEFAULT_MAX_ENTRIES)
24
+ @max_entries = [max_entries.to_i, 0].max
25
+ @entries = Concurrent::Hash.new
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ # The cached prompt while it is still fresh, otherwise nil.
30
+ def read(key, ttl_seconds)
31
+ entry = @entries[key]
32
+ return nil unless entry&.fresh?(ttl_seconds, monotonic_time)
33
+
34
+ entry.prompt
35
+ end
36
+
37
+ # The cached prompt regardless of age. Used to keep serving prompts while the
38
+ # Langfuse API is unreachable.
39
+ def read_stale(key)
40
+ @entries[key]&.prompt
41
+ end
42
+
43
+ # Writes are serialized so concurrent fetches cannot corrupt the hash while it
44
+ # is being trimmed. Returns the prompt for convenient chaining.
45
+ def write(key, prompt)
46
+ @mutex.synchronize do
47
+ @entries.delete(key)
48
+ @entries[key] = Entry.new(prompt, monotonic_time)
49
+ @entries.shift while @entries.length > @max_entries && !@entries.empty?
50
+ end
51
+
52
+ prompt
53
+ end
54
+
55
+ def length
56
+ @entries.length
57
+ end
58
+
59
+ private
60
+
61
+ def monotonic_time
62
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ end
64
+ end
65
+ end
data/lib/langfuse/span.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Langfuse
4
4
  class Span
5
+ include PartialUpdates
6
+
5
7
  attr_reader :id, :trace_id, :name, :start_time, :end_time, :input, :output,
6
8
  :metadata, :level, :status_message, :parent_observation_id, :version,
7
9
  :as_type, :client
@@ -31,25 +33,32 @@ module Langfuse
31
33
 
32
34
  def update(name: nil, end_time: nil, input: nil, output: nil, metadata: nil,
33
35
  level: nil, status_message: nil, version: nil, **kwargs)
34
- @name = name if name
35
- @end_time = end_time if end_time
36
- @input = input if input
37
- @output = output if output
36
+ @name = name unless name.nil?
37
+ @end_time = end_time unless end_time.nil?
38
+ @input = input unless input.nil?
39
+ @output = output unless output.nil?
38
40
  @metadata.merge!(metadata) if metadata
39
- @level = level if level
40
- @status_message = status_message if status_message
41
- @version = version if version
41
+ @level = level unless level.nil?
42
+ @status_message = status_message unless status_message.nil?
43
+ @version = version unless version.nil?
42
44
  @kwargs.merge!(kwargs)
43
45
 
46
+ track_changes(
47
+ { name: name, end_time: end_time, input: input, output: output,
48
+ metadata: metadata, level: level, status_message: status_message,
49
+ version: version },
50
+ kwargs.keys
51
+ )
44
52
  update_span
45
53
  self
46
54
  end
47
55
 
48
56
  def end(output: nil, end_time: nil, **kwargs)
49
57
  @end_time = end_time || Utils.current_timestamp
50
- @output = output if output
58
+ @output = output unless output.nil?
51
59
  @kwargs.merge!(kwargs)
52
60
 
61
+ track_changes({ end_time: @end_time, output: output }, kwargs.keys)
53
62
  update_span
54
63
  self
55
64
  end
@@ -77,6 +86,7 @@ module Langfuse
77
86
  # Create a child generation
78
87
  def generation(name: nil, start_time: nil, end_time: nil, completion_start_time: nil,
79
88
  model: nil, model_parameters: nil, input: nil, output: nil, usage: nil,
89
+ usage_details: nil, cost_details: nil, prompt: nil,
80
90
  metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
81
91
  @client.generation(
82
92
  trace_id: @trace_id,
@@ -89,6 +99,9 @@ module Langfuse
89
99
  input: input,
90
100
  output: output,
91
101
  usage: usage,
102
+ usage_details: usage_details,
103
+ cost_details: cost_details,
104
+ prompt: prompt,
92
105
  metadata: metadata,
93
106
  level: level,
94
107
  status_message: status_message,
@@ -116,79 +129,11 @@ module Langfuse
116
129
  )
117
130
  end
118
131
 
119
- # Convenience methods for enhanced observation types
120
-
121
- # Create a child agent observation
122
- def agent(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
123
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
124
- span(
125
- name: name,
126
- start_time: start_time,
127
- end_time: end_time,
128
- input: input,
129
- output: output,
130
- metadata: metadata,
131
- level: level,
132
- status_message: status_message,
133
- version: version,
134
- as_type: ObservationType::AGENT,
135
- **kwargs
136
- )
137
- end
138
-
139
- # Create a child tool observation
140
- def tool(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
141
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
142
- span(
143
- name: name,
144
- start_time: start_time,
145
- end_time: end_time,
146
- input: input,
147
- output: output,
148
- metadata: metadata,
149
- level: level,
150
- status_message: status_message,
151
- version: version,
152
- as_type: ObservationType::TOOL,
153
- **kwargs
154
- )
155
- end
156
-
157
- # Create a child chain observation
158
- def chain(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
159
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
160
- span(
161
- name: name,
162
- start_time: start_time,
163
- end_time: end_time,
164
- input: input,
165
- output: output,
166
- metadata: metadata,
167
- level: level,
168
- status_message: status_message,
169
- version: version,
170
- as_type: ObservationType::CHAIN,
171
- **kwargs
172
- )
173
- end
174
-
175
- # Create a child retriever observation
176
- def retriever(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
177
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
178
- span(
179
- name: name,
180
- start_time: start_time,
181
- end_time: end_time,
182
- input: input,
183
- output: output,
184
- metadata: metadata,
185
- level: level,
186
- status_message: status_message,
187
- version: version,
188
- as_type: ObservationType::RETRIEVER,
189
- **kwargs
190
- )
191
- end
132
+ # Convenience methods for enhanced observation types: each is a child span
133
+ # with a fixed as_type. (embedding keeps its own definition because it folds
134
+ # model/usage into metadata first.)
135
+ extend SpanWrappers
136
+ define_span_wrappers
192
137
 
193
138
  # Create a child embedding observation
194
139
  def embedding(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
@@ -212,44 +157,9 @@ module Langfuse
212
157
  )
213
158
  end
214
159
 
215
- # Create a child evaluator observation
216
- def evaluator(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
217
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
218
- span(
219
- name: name,
220
- start_time: start_time,
221
- end_time: end_time,
222
- input: input,
223
- output: output,
224
- metadata: metadata,
225
- level: level,
226
- status_message: status_message,
227
- version: version,
228
- as_type: ObservationType::EVALUATOR,
229
- **kwargs
230
- )
231
- end
232
-
233
- # Create a child guardrail observation
234
- def guardrail(name: nil, start_time: nil, end_time: nil, input: nil, output: nil,
235
- metadata: nil, level: nil, status_message: nil, version: nil, **kwargs)
236
- span(
237
- name: name,
238
- start_time: start_time,
239
- end_time: end_time,
240
- input: input,
241
- output: output,
242
- metadata: metadata,
243
- level: level,
244
- status_message: status_message,
245
- version: version,
246
- as_type: ObservationType::GUARDRAIL,
247
- **kwargs
248
- )
249
- end
250
-
251
160
  def score(name:, value:, data_type: nil, comment: nil, **kwargs)
252
161
  @client.score(
162
+ trace_id: @trace_id,
253
163
  observation_id: @id,
254
164
  name: name,
255
165
  value: value,
@@ -294,45 +204,11 @@ module Langfuse
294
204
  end
295
205
 
296
206
  def create_span
297
- data = {
298
- id: @id,
299
- trace_id: @trace_id,
300
- name: @name,
301
- start_time: @start_time,
302
- end_time: @end_time,
303
- input: @input,
304
- output: @output,
305
- metadata: @metadata,
306
- level: @level,
307
- status_message: @status_message,
308
- parent_observation_id: @parent_observation_id,
309
- version: @version
310
- }
311
- data[:type] = @as_type if @as_type
312
- data = data.merge(@kwargs).compact
313
-
314
- @client.enqueue_event('span-create', data)
207
+ @client.enqueue_event('span-create', to_dict)
315
208
  end
316
209
 
317
210
  def update_span
318
- data = {
319
- id: @id,
320
- trace_id: @trace_id,
321
- name: @name,
322
- start_time: @start_time,
323
- end_time: @end_time,
324
- input: @input,
325
- output: @output,
326
- metadata: @metadata,
327
- level: @level,
328
- status_message: @status_message,
329
- parent_observation_id: @parent_observation_id,
330
- version: @version
331
- }
332
- data[:type] = @as_type if @as_type
333
- data = data.merge(@kwargs).compact
334
-
335
- @client.enqueue_event('span-update', data)
211
+ @client.enqueue_event('span-update', update_body)
336
212
  end
337
213
  end
338
214
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Defines the enhanced observation helpers (agent, tool, chain, retriever,
5
+ # evaluator, guardrail) as thin wrappers around the extending class's #span,
6
+ # each pinning a fixed as_type.
7
+ #
8
+ # `embedding` is intentionally not generated here: every wrapper folds
9
+ # model/usage into metadata before delegating, so it stays hand-written.
10
+ module SpanWrappers
11
+ TYPES = {
12
+ agent: ObservationType::AGENT,
13
+ tool: ObservationType::TOOL,
14
+ chain: ObservationType::CHAIN,
15
+ retriever: ObservationType::RETRIEVER,
16
+ evaluator: ObservationType::EVALUATOR,
17
+ guardrail: ObservationType::GUARDRAIL
18
+ }.freeze
19
+
20
+ # `evaluator_name` exists because Client exposes the helper as
21
+ # `evaluator_obs`, keeping it distinct from the Evaluators API.
22
+ def define_span_wrappers(evaluator_name: :evaluator)
23
+ TYPES.each do |name, as_type|
24
+ method_name = name == :evaluator ? evaluator_name : name
25
+
26
+ define_method(method_name) do |**kwargs|
27
+ span(**kwargs, as_type: as_type)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Langfuse
4
+ # Placeholder handling shared by Prompt, PromptTemplate and ChatPromptTemplate.
5
+ # Both `{{var}}` and `{var}` are supported.
6
+ module TemplateCompiler
7
+ VARIABLE_PATTERN = /\{\{([a-zA-Z0-9_.]+)\}\}|\{([a-zA-Z0-9_.]+)\}/
8
+
9
+ class << self
10
+ # Substitutes every provided variable in a single pass, so a value that
11
+ # itself contains a placeholder is never expanded again (a value like
12
+ # "{{admin_prompt}}" stays literal). Placeholders without a matching
13
+ # variable are left untouched.
14
+ def compile(template, variables = {})
15
+ text = template.to_s
16
+ return text if variables.nil? || variables.empty?
17
+
18
+ values = variables.to_h { |key, value| [key.to_s, value.to_s] }
19
+
20
+ text.gsub(placeholder_pattern(values.keys)) do
21
+ values[Regexp.last_match(1) || Regexp.last_match(2)]
22
+ end
23
+ end
24
+
25
+ def compile_messages(messages, variables = {})
26
+ messages.map do |message|
27
+ role = message[:role] || message['role']
28
+ content = compile(message[:content] || message['content'], variables)
29
+ message.merge(role: role, content: content)
30
+ end
31
+ end
32
+
33
+ def extract_variables(text)
34
+ text.to_s.scan(VARIABLE_PATTERN).map { |double, single| double || single }.uniq
35
+ end
36
+
37
+ def extract_message_variables(messages)
38
+ messages.flat_map do |message|
39
+ content = message[:content] || message['content']
40
+ extract_variables(content)
41
+ end.uniq
42
+ end
43
+
44
+ private
45
+
46
+ # Longest name first so a `user` variable cannot shadow `{{user_name}}`.
47
+ def placeholder_pattern(names)
48
+ alternatives = names.sort_by { |name| -name.length }
49
+ .map { |name| Regexp.escape(name) }
50
+ .join('|')
51
+
52
+ /\{\{(#{alternatives})\}\}|\{(#{alternatives})\}/
53
+ end
54
+ end
55
+ end
56
+ end