rubyllm-observ 0.6.5 → 0.6.7
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.
- checksums.yaml +4 -4
- data/README.md +319 -2
- data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
- data/app/assets/javascripts/observ/controllers/index.js +29 -0
- data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
- data/app/assets/stylesheets/observ/_chat.scss +199 -0
- data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
- data/app/assets/stylesheets/observ/application.scss +1 -0
- data/app/controllers/observ/dataset_items_controller.rb +2 -2
- data/app/controllers/observ/dataset_runs_controller.rb +1 -1
- data/app/controllers/observ/datasets_controller.rb +2 -2
- data/app/controllers/observ/messages_controller.rb +5 -1
- data/app/controllers/observ/prompts_controller.rb +11 -3
- data/app/controllers/observ/scores_controller.rb +1 -1
- data/app/controllers/observ/traces_controller.rb +1 -1
- data/app/helpers/observ/application_helper.rb +1 -0
- data/app/helpers/observ/markdown_helper.rb +29 -0
- data/app/helpers/observ/prompts_helper.rb +48 -0
- data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
- data/app/models/observ/embedding.rb +45 -0
- data/app/models/observ/image_generation.rb +38 -0
- data/app/models/observ/moderation.rb +40 -0
- data/app/models/observ/null_prompt.rb +49 -2
- data/app/models/observ/observation.rb +3 -1
- data/app/models/observ/session.rb +33 -0
- data/app/models/observ/trace.rb +90 -4
- data/app/models/observ/transcription.rb +38 -0
- data/app/services/observ/chat_instrumenter.rb +96 -6
- data/app/services/observ/concerns/observable_service.rb +108 -3
- data/app/services/observ/embedding_instrumenter.rb +193 -0
- data/app/services/observ/guardrail_service.rb +9 -0
- data/app/services/observ/image_generation_instrumenter.rb +243 -0
- data/app/services/observ/moderation_guardrail_service.rb +235 -0
- data/app/services/observ/moderation_instrumenter.rb +141 -0
- data/app/services/observ/transcription_instrumenter.rb +187 -0
- data/app/views/layouts/observ/application.html.erb +1 -1
- data/app/views/observ/chats/show.html.erb +9 -0
- data/app/views/observ/messages/_message.html.erb +1 -1
- data/app/views/observ/messages/create.turbo_stream.erb +1 -3
- data/app/views/observ/prompts/_config_editor.html.erb +115 -0
- data/app/views/observ/prompts/_form.html.erb +2 -13
- data/app/views/observ/prompts/_new_form.html.erb +2 -12
- data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
- data/lib/observ/configuration.rb +0 -2
- data/lib/observ/engine.rb +7 -0
- data/lib/observ/version.rb +1 -1
- metadata +31 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class EmbeddingInstrumenter
|
|
5
|
+
attr_reader :session, :context
|
|
6
|
+
|
|
7
|
+
def initialize(session, context: {})
|
|
8
|
+
@session = session
|
|
9
|
+
@context = context
|
|
10
|
+
@original_embed_method = nil
|
|
11
|
+
@instrumented = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def instrument!
|
|
15
|
+
return if @instrumented
|
|
16
|
+
|
|
17
|
+
wrap_embed_method
|
|
18
|
+
@instrumented = true
|
|
19
|
+
|
|
20
|
+
Rails.logger.info "[Observability] Instrumented RubyLLM.embed for session #{session.session_id}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def uninstrument!
|
|
24
|
+
return unless @instrumented
|
|
25
|
+
return unless @original_embed_method
|
|
26
|
+
|
|
27
|
+
RubyLLM.define_singleton_method(:embed, @original_embed_method)
|
|
28
|
+
@instrumented = false
|
|
29
|
+
|
|
30
|
+
Rails.logger.info "[Observability] Uninstrumented RubyLLM.embed"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def wrap_embed_method
|
|
36
|
+
return if @original_embed_method
|
|
37
|
+
|
|
38
|
+
@original_embed_method = RubyLLM.method(:embed)
|
|
39
|
+
instrumenter = self
|
|
40
|
+
|
|
41
|
+
RubyLLM.define_singleton_method(:embed) do |*args, **kwargs|
|
|
42
|
+
instrumenter.send(:handle_embed_call, args, kwargs)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_embed_call(args, kwargs)
|
|
47
|
+
texts = args[0]
|
|
48
|
+
model_id = kwargs[:model] || default_embedding_model
|
|
49
|
+
|
|
50
|
+
trace = session.create_trace(
|
|
51
|
+
name: "embedding",
|
|
52
|
+
input: format_input(texts),
|
|
53
|
+
metadata: @context.merge(
|
|
54
|
+
batch_size: Array(texts).size,
|
|
55
|
+
model: model_id
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
embedding_obs = trace.create_embedding(
|
|
60
|
+
name: "embed",
|
|
61
|
+
model: model_id,
|
|
62
|
+
metadata: {
|
|
63
|
+
batch_size: Array(texts).size
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
embedding_obs.set_input(texts)
|
|
67
|
+
|
|
68
|
+
call_start_time = Time.current
|
|
69
|
+
result = @original_embed_method.call(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
finalize_embedding(embedding_obs, result, call_start_time)
|
|
72
|
+
trace.finalize(
|
|
73
|
+
output: format_output(result),
|
|
74
|
+
metadata: { dimensions: extract_dimensions(result) }
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
result
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
handle_error(e, trace, embedding_obs)
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finalize_embedding(embedding_obs, result, _call_start_time)
|
|
84
|
+
usage = extract_usage(result)
|
|
85
|
+
cost = calculate_cost(result)
|
|
86
|
+
dimensions = extract_dimensions(result)
|
|
87
|
+
vectors_count = extract_vectors_count(result)
|
|
88
|
+
|
|
89
|
+
embedding_obs.finalize(
|
|
90
|
+
output: format_output(result),
|
|
91
|
+
usage: usage,
|
|
92
|
+
cost_usd: cost
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
embedding_obs.update!(
|
|
96
|
+
metadata: embedding_obs.metadata.merge(
|
|
97
|
+
dimensions: dimensions,
|
|
98
|
+
vectors_count: vectors_count
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_usage(result)
|
|
104
|
+
{
|
|
105
|
+
input_tokens: result.input_tokens || 0,
|
|
106
|
+
total_tokens: result.input_tokens || 0
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def calculate_cost(result)
|
|
111
|
+
model_id = result.model
|
|
112
|
+
return 0.0 unless model_id
|
|
113
|
+
|
|
114
|
+
model_info = RubyLLM.models.find(model_id)
|
|
115
|
+
return 0.0 unless model_info&.input_price_per_million
|
|
116
|
+
|
|
117
|
+
input_tokens = result.input_tokens || 0
|
|
118
|
+
(input_tokens * model_info.input_price_per_million / 1_000_000.0).round(6)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
Rails.logger.warn "[Observability] Failed to calculate embedding cost: #{e.message}"
|
|
121
|
+
0.0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def extract_dimensions(result)
|
|
125
|
+
vectors = result.vectors
|
|
126
|
+
return nil unless vectors
|
|
127
|
+
|
|
128
|
+
# Handle both single embedding and batch embeddings
|
|
129
|
+
if vectors.first.is_a?(Array)
|
|
130
|
+
vectors.first.length
|
|
131
|
+
else
|
|
132
|
+
vectors.length
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def extract_vectors_count(result)
|
|
137
|
+
vectors = result.vectors
|
|
138
|
+
return 1 unless vectors
|
|
139
|
+
|
|
140
|
+
# Handle both single embedding and batch embeddings
|
|
141
|
+
if vectors.first.is_a?(Array)
|
|
142
|
+
vectors.length
|
|
143
|
+
else
|
|
144
|
+
1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def format_input(texts)
|
|
149
|
+
if texts.is_a?(Array)
|
|
150
|
+
{ texts: texts, count: texts.size }
|
|
151
|
+
else
|
|
152
|
+
{ text: texts }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def format_output(result)
|
|
157
|
+
{
|
|
158
|
+
model: result.model,
|
|
159
|
+
dimensions: extract_dimensions(result),
|
|
160
|
+
vectors_count: extract_vectors_count(result)
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def default_embedding_model
|
|
165
|
+
if RubyLLM.config.respond_to?(:default_embedding_model)
|
|
166
|
+
RubyLLM.config.default_embedding_model
|
|
167
|
+
else
|
|
168
|
+
"text-embedding-3-small"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handle_error(error, trace, embedding_obs)
|
|
173
|
+
return unless trace
|
|
174
|
+
|
|
175
|
+
error_span = trace.create_span(
|
|
176
|
+
name: "error",
|
|
177
|
+
metadata: {
|
|
178
|
+
error_type: error.class.name,
|
|
179
|
+
level: "ERROR"
|
|
180
|
+
},
|
|
181
|
+
input: {
|
|
182
|
+
error_message: error.message,
|
|
183
|
+
backtrace: error.backtrace&.first(10)
|
|
184
|
+
}.to_json
|
|
185
|
+
)
|
|
186
|
+
error_span.finalize(output: { error_captured: true }.to_json)
|
|
187
|
+
|
|
188
|
+
embedding_obs&.update(status_message: "FAILED") rescue nil
|
|
189
|
+
|
|
190
|
+
Rails.logger.error "[Observability] Embedding error captured: #{error.class.name} - #{error.message}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -65,6 +65,15 @@ module Observ
|
|
|
65
65
|
condition: ->(t) { t.metadata&.dig("error").present? },
|
|
66
66
|
details: ->(t) { { error: t.metadata["error"] } }
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
name: :error_span,
|
|
70
|
+
priority: :critical,
|
|
71
|
+
condition: ->(t) { t.observations.exists?(type: "Observ::Span", name: "error") },
|
|
72
|
+
details: ->(t) {
|
|
73
|
+
error_span = t.observations.find_by(type: "Observ::Span", name: "error")
|
|
74
|
+
{ span_id: error_span&.observation_id, metadata: error_span&.metadata }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
68
77
|
{
|
|
69
78
|
name: :high_cost,
|
|
70
79
|
priority: :high,
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class ImageGenerationInstrumenter
|
|
5
|
+
# Hardcoded pricing for image generation models (USD per image)
|
|
6
|
+
# Prices are organized by model_id, then by size, then by quality
|
|
7
|
+
# Source: https://openai.com/pricing, https://cloud.google.com/vertex-ai/pricing
|
|
8
|
+
IMAGE_PRICING = {
|
|
9
|
+
# OpenAI DALL-E 3 (size and quality based)
|
|
10
|
+
# Quality options: "standard", "hd"
|
|
11
|
+
"dall-e-3" => {
|
|
12
|
+
"1024x1024" => { "standard" => 0.04, "hd" => 0.08 },
|
|
13
|
+
"1792x1024" => { "standard" => 0.08, "hd" => 0.12 },
|
|
14
|
+
"1024x1792" => { "standard" => 0.08, "hd" => 0.12 }
|
|
15
|
+
},
|
|
16
|
+
# OpenAI DALL-E 2 (size based, no quality option)
|
|
17
|
+
"dall-e-2" => {
|
|
18
|
+
"1024x1024" => { "default" => 0.02 },
|
|
19
|
+
"512x512" => { "default" => 0.018 },
|
|
20
|
+
"256x256" => { "default" => 0.016 }
|
|
21
|
+
},
|
|
22
|
+
# OpenAI GPT-image-1 (token-based, estimated per-image costs)
|
|
23
|
+
# Quality options: "low", "medium", "high" (maps "standard" -> "medium")
|
|
24
|
+
# Source: "Image outputs cost approximately $0.01 (low), $0.04 (medium), $0.17 (high) for square images"
|
|
25
|
+
# Larger sizes are estimated at ~1.7x for 1792x1024 and ~2.9x for 1792x1792
|
|
26
|
+
"gpt-image-1" => {
|
|
27
|
+
"1024x1024" => { "low" => 0.01, "medium" => 0.04, "high" => 0.17 },
|
|
28
|
+
"1792x1024" => { "low" => 0.017, "medium" => 0.068, "high" => 0.29 },
|
|
29
|
+
"1024x1792" => { "low" => 0.017, "medium" => 0.068, "high" => 0.29 },
|
|
30
|
+
"1792x1792" => { "low" => 0.029, "medium" => 0.116, "high" => 0.49 },
|
|
31
|
+
"default" => { "low" => 0.01, "medium" => 0.04, "high" => 0.17 }
|
|
32
|
+
},
|
|
33
|
+
# OpenAI GPT-image-1-mini (token-based, estimated per-image costs)
|
|
34
|
+
# Approximately 5x cheaper than gpt-image-1 based on token pricing ratio
|
|
35
|
+
"gpt-image-1-mini" => {
|
|
36
|
+
"1024x1024" => { "low" => 0.002, "medium" => 0.008, "high" => 0.034 },
|
|
37
|
+
"1792x1024" => { "low" => 0.0034, "medium" => 0.0136, "high" => 0.058 },
|
|
38
|
+
"1024x1792" => { "low" => 0.0034, "medium" => 0.0136, "high" => 0.058 },
|
|
39
|
+
"1792x1792" => { "low" => 0.0058, "medium" => 0.0232, "high" => 0.098 },
|
|
40
|
+
"default" => { "low" => 0.002, "medium" => 0.008, "high" => 0.034 }
|
|
41
|
+
},
|
|
42
|
+
# Google Imagen models (flat rate per image)
|
|
43
|
+
"imagen-3.0-generate-002" => {
|
|
44
|
+
"default" => { "default" => 0.04 }
|
|
45
|
+
},
|
|
46
|
+
"imagen-4.0-generate-001" => {
|
|
47
|
+
"default" => { "default" => 0.04 }
|
|
48
|
+
},
|
|
49
|
+
"imagen-4.0-generate-preview-06-06" => {
|
|
50
|
+
"default" => { "default" => 0.04 }
|
|
51
|
+
},
|
|
52
|
+
"imagen-4.0-ultra-generate-preview-06-06" => {
|
|
53
|
+
"default" => { "default" => 0.08 }
|
|
54
|
+
}
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
# Maps quality names between different conventions
|
|
58
|
+
# DALL-E uses: "standard", "hd"
|
|
59
|
+
# GPT-image uses: "low", "medium", "high"
|
|
60
|
+
QUALITY_MAPPINGS = {
|
|
61
|
+
"standard" => "medium", # Map DALL-E "standard" to GPT-image "medium"
|
|
62
|
+
"hd" => "high" # Map DALL-E "hd" to GPT-image "high"
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
attr_reader :session, :context
|
|
66
|
+
|
|
67
|
+
def initialize(session, context: {})
|
|
68
|
+
@session = session
|
|
69
|
+
@context = context
|
|
70
|
+
@original_paint_method = nil
|
|
71
|
+
@instrumented = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def instrument!
|
|
75
|
+
return if @instrumented
|
|
76
|
+
|
|
77
|
+
wrap_paint_method
|
|
78
|
+
@instrumented = true
|
|
79
|
+
|
|
80
|
+
Rails.logger.info "[Observability] Instrumented RubyLLM.paint for session #{session.session_id}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def uninstrument!
|
|
84
|
+
return unless @instrumented
|
|
85
|
+
return unless @original_paint_method
|
|
86
|
+
|
|
87
|
+
RubyLLM.define_singleton_method(:paint, @original_paint_method)
|
|
88
|
+
@instrumented = false
|
|
89
|
+
|
|
90
|
+
Rails.logger.info "[Observability] Uninstrumented RubyLLM.paint"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def wrap_paint_method
|
|
96
|
+
return if @original_paint_method
|
|
97
|
+
|
|
98
|
+
@original_paint_method = RubyLLM.method(:paint)
|
|
99
|
+
instrumenter = self
|
|
100
|
+
|
|
101
|
+
RubyLLM.define_singleton_method(:paint) do |*args, **kwargs|
|
|
102
|
+
instrumenter.send(:handle_paint_call, args, kwargs)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_paint_call(args, kwargs)
|
|
107
|
+
prompt = args[0]
|
|
108
|
+
model_id = kwargs[:model] || default_image_model
|
|
109
|
+
size = kwargs[:size] || "1024x1024"
|
|
110
|
+
quality = kwargs[:quality] || "standard"
|
|
111
|
+
|
|
112
|
+
trace = session.create_trace(
|
|
113
|
+
name: "image_generation",
|
|
114
|
+
input: { prompt: prompt },
|
|
115
|
+
metadata: @context.merge(
|
|
116
|
+
model: model_id,
|
|
117
|
+
size: size,
|
|
118
|
+
quality: quality
|
|
119
|
+
).compact
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
image_obs = trace.create_image_generation(
|
|
123
|
+
name: "paint",
|
|
124
|
+
model: model_id,
|
|
125
|
+
metadata: {
|
|
126
|
+
size: size,
|
|
127
|
+
quality: quality
|
|
128
|
+
}.compact
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
result = @original_paint_method.call(*args, **kwargs)
|
|
132
|
+
|
|
133
|
+
finalize_image_generation(image_obs, result, prompt, size: size, quality: quality)
|
|
134
|
+
trace.finalize(
|
|
135
|
+
output: format_output(result),
|
|
136
|
+
metadata: { size: extract_size(result) || size, quality: quality }
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
result
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
handle_error(e, trace, image_obs)
|
|
142
|
+
raise
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def finalize_image_generation(image_obs, result, prompt, size:, quality:)
|
|
146
|
+
cost = calculate_cost(result, size: size, quality: quality)
|
|
147
|
+
|
|
148
|
+
image_obs.finalize(
|
|
149
|
+
output: format_output(result),
|
|
150
|
+
usage: {},
|
|
151
|
+
cost_usd: cost
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
image_obs.update!(
|
|
155
|
+
input: prompt,
|
|
156
|
+
metadata: image_obs.metadata.merge(
|
|
157
|
+
revised_prompt: result.revised_prompt,
|
|
158
|
+
output_format: result.base64? ? "base64" : "url",
|
|
159
|
+
mime_type: result.mime_type,
|
|
160
|
+
size: extract_size(result) || size,
|
|
161
|
+
quality: quality
|
|
162
|
+
).compact
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def calculate_cost(result, size:, quality:)
|
|
167
|
+
model_id = result.model_id
|
|
168
|
+
return 0.0 unless model_id
|
|
169
|
+
|
|
170
|
+
lookup_image_price(model_id, size, quality)
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
Rails.logger.warn "[Observability] Failed to calculate image generation cost: #{e.message}"
|
|
173
|
+
0.0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def lookup_image_price(model_id, size, quality)
|
|
177
|
+
model_pricing = IMAGE_PRICING[model_id]
|
|
178
|
+
return 0.0 unless model_pricing
|
|
179
|
+
|
|
180
|
+
# Try exact size match, then "default"
|
|
181
|
+
size_pricing = model_pricing[size] || model_pricing["default"]
|
|
182
|
+
return 0.0 unless size_pricing
|
|
183
|
+
|
|
184
|
+
# Try exact quality match first
|
|
185
|
+
return size_pricing[quality] if size_pricing[quality]
|
|
186
|
+
|
|
187
|
+
# Try mapped quality (e.g., "standard" -> "medium" for GPT-image models)
|
|
188
|
+
mapped_quality = QUALITY_MAPPINGS[quality]
|
|
189
|
+
return size_pricing[mapped_quality] if mapped_quality && size_pricing[mapped_quality]
|
|
190
|
+
|
|
191
|
+
# Fall back to "standard", "medium", "default", or first available
|
|
192
|
+
size_pricing["standard"] ||
|
|
193
|
+
size_pricing["medium"] ||
|
|
194
|
+
size_pricing["default"] ||
|
|
195
|
+
size_pricing.values.first ||
|
|
196
|
+
0.0
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def extract_size(result)
|
|
200
|
+
# Try to get size from result if available
|
|
201
|
+
result.respond_to?(:size) ? result.size : nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_output(result)
|
|
205
|
+
{
|
|
206
|
+
model: result.model_id,
|
|
207
|
+
has_url: result.respond_to?(:url) && result.url.present?,
|
|
208
|
+
base64: result.base64?,
|
|
209
|
+
mime_type: result.mime_type,
|
|
210
|
+
revised_prompt: result.revised_prompt
|
|
211
|
+
}.compact
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def default_image_model
|
|
215
|
+
if RubyLLM.config.respond_to?(:default_image_model)
|
|
216
|
+
RubyLLM.config.default_image_model
|
|
217
|
+
else
|
|
218
|
+
"dall-e-3"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def handle_error(error, trace, image_obs)
|
|
223
|
+
return unless trace
|
|
224
|
+
|
|
225
|
+
error_span = trace.create_span(
|
|
226
|
+
name: "error",
|
|
227
|
+
metadata: {
|
|
228
|
+
error_type: error.class.name,
|
|
229
|
+
level: "ERROR"
|
|
230
|
+
},
|
|
231
|
+
input: {
|
|
232
|
+
error_message: error.message,
|
|
233
|
+
backtrace: error.backtrace&.first(10)
|
|
234
|
+
}.to_json
|
|
235
|
+
)
|
|
236
|
+
error_span.finalize(output: { error_captured: true }.to_json)
|
|
237
|
+
|
|
238
|
+
image_obs&.update(status_message: "FAILED") rescue nil
|
|
239
|
+
|
|
240
|
+
Rails.logger.error "[Observability] Image generation error captured: #{error.class.name} - #{error.message}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Observ
|
|
4
|
+
class ModerationGuardrailService
|
|
5
|
+
include Observ::Concerns::ObservableService
|
|
6
|
+
|
|
7
|
+
# Score thresholds for different actions
|
|
8
|
+
THRESHOLDS = {
|
|
9
|
+
critical: 0.9, # Auto-flag as critical
|
|
10
|
+
high: 0.7, # Flag as high priority
|
|
11
|
+
review: 0.5 # Flag for normal review
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# Categories that always trigger critical review
|
|
15
|
+
CRITICAL_CATEGORIES = %w[
|
|
16
|
+
sexual/minors
|
|
17
|
+
self-harm/intent
|
|
18
|
+
self-harm/instructions
|
|
19
|
+
violence/graphic
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
class Result
|
|
23
|
+
attr_reader :action, :reason, :priority, :details
|
|
24
|
+
|
|
25
|
+
def initialize(action:, reason: nil, priority: nil, details: {})
|
|
26
|
+
@action = action
|
|
27
|
+
@reason = reason
|
|
28
|
+
@priority = priority
|
|
29
|
+
@details = details
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def flagged? = action == :flagged
|
|
33
|
+
def skipped? = action == :skipped
|
|
34
|
+
def passed? = action == :passed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(observability_session: nil)
|
|
38
|
+
initialize_observability(
|
|
39
|
+
observability_session,
|
|
40
|
+
service_name: "moderation_guardrail",
|
|
41
|
+
metadata: {}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Evaluate a trace for moderation issues
|
|
46
|
+
#
|
|
47
|
+
# @param trace [Observ::Trace] The trace to evaluate
|
|
48
|
+
# @param moderate_input [Boolean] Whether to moderate input content
|
|
49
|
+
# @param moderate_output [Boolean] Whether to moderate output content
|
|
50
|
+
# @return [Result] The evaluation result
|
|
51
|
+
def evaluate_trace(trace, moderate_input: true, moderate_output: true)
|
|
52
|
+
return Result.new(action: :skipped, reason: "already_in_queue") if trace.in_review_queue?
|
|
53
|
+
return Result.new(action: :skipped, reason: "already_has_moderation") if has_existing_flags?(trace)
|
|
54
|
+
|
|
55
|
+
with_observability do |_session|
|
|
56
|
+
content = extract_trace_content(trace, moderate_input:, moderate_output:)
|
|
57
|
+
return Result.new(action: :skipped, reason: "no_content") if content.blank?
|
|
58
|
+
|
|
59
|
+
perform_moderation(trace, content)
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger.error "[ModerationGuardrailService] Failed to evaluate trace #{trace.id}: #{e.message}"
|
|
63
|
+
Result.new(action: :skipped, reason: "error", details: { error: e.message })
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Evaluate all traces in a session
|
|
67
|
+
#
|
|
68
|
+
# @param session [Observ::Session] The session to evaluate
|
|
69
|
+
# @return [Array<Result>] Results for each trace
|
|
70
|
+
def evaluate_session(session)
|
|
71
|
+
return [] if session.traces.empty?
|
|
72
|
+
|
|
73
|
+
session.traces.map do |trace|
|
|
74
|
+
evaluate_trace(trace)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Evaluate session-level content (aggregated input/output)
|
|
79
|
+
#
|
|
80
|
+
# @param session [Observ::Session] The session to evaluate
|
|
81
|
+
# @return [Result] The evaluation result
|
|
82
|
+
def evaluate_session_content(session)
|
|
83
|
+
return Result.new(action: :skipped, reason: "already_in_queue") if session.in_review_queue?
|
|
84
|
+
|
|
85
|
+
with_observability do |_session|
|
|
86
|
+
content = extract_session_content(session)
|
|
87
|
+
return Result.new(action: :skipped, reason: "no_content") if content.blank?
|
|
88
|
+
|
|
89
|
+
perform_session_moderation(session, content)
|
|
90
|
+
end
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Rails.logger.error "[ModerationGuardrailService] Failed to evaluate session #{session.id}: #{e.message}"
|
|
93
|
+
Result.new(action: :skipped, reason: "error", details: { error: e.message })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def has_existing_flags?(trace)
|
|
99
|
+
trace.moderations.any?(&:flagged?)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_trace_content(trace, moderate_input:, moderate_output:)
|
|
103
|
+
parts = []
|
|
104
|
+
parts << extract_text(trace.input) if moderate_input
|
|
105
|
+
parts << extract_text(trace.output) if moderate_output
|
|
106
|
+
parts.compact.reject(&:blank?).join("\n\n---\n\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def extract_session_content(session)
|
|
110
|
+
session.traces.flat_map do |trace|
|
|
111
|
+
[extract_text(trace.input), extract_text(trace.output)]
|
|
112
|
+
end.compact.reject(&:blank?).join("\n\n---\n\n").truncate(10_000)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def extract_text(content)
|
|
116
|
+
return nil if content.blank?
|
|
117
|
+
|
|
118
|
+
case content
|
|
119
|
+
when String
|
|
120
|
+
content
|
|
121
|
+
when Hash
|
|
122
|
+
# Try common keys for text content
|
|
123
|
+
content["text"] || content["content"] || content["message"] ||
|
|
124
|
+
content[:text] || content[:content] || content[:message] ||
|
|
125
|
+
content.to_json
|
|
126
|
+
else
|
|
127
|
+
content.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def perform_moderation(trace, content)
|
|
132
|
+
instrument_moderation(context: {
|
|
133
|
+
service: "moderation_guardrail",
|
|
134
|
+
trace_id: trace.id,
|
|
135
|
+
content_length: content.length
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
result = RubyLLM.moderate(content)
|
|
139
|
+
|
|
140
|
+
evaluate_and_enqueue(trace, result)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def perform_session_moderation(session, content)
|
|
144
|
+
instrument_moderation(context: {
|
|
145
|
+
service: "moderation_guardrail",
|
|
146
|
+
session_id: session.id,
|
|
147
|
+
content_length: content.length
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
result = RubyLLM.moderate(content)
|
|
151
|
+
|
|
152
|
+
evaluate_and_enqueue_session(session, result)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def evaluate_and_enqueue(trace, moderation_result)
|
|
156
|
+
priority = determine_priority(moderation_result)
|
|
157
|
+
|
|
158
|
+
if priority
|
|
159
|
+
details = build_details(moderation_result)
|
|
160
|
+
trace.enqueue_for_review!(
|
|
161
|
+
reason: "content_moderation",
|
|
162
|
+
priority: priority,
|
|
163
|
+
details: details
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
Result.new(
|
|
167
|
+
action: :flagged,
|
|
168
|
+
priority: priority,
|
|
169
|
+
details: details
|
|
170
|
+
)
|
|
171
|
+
else
|
|
172
|
+
Result.new(action: :passed)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def evaluate_and_enqueue_session(session, moderation_result)
|
|
177
|
+
priority = determine_priority(moderation_result)
|
|
178
|
+
|
|
179
|
+
if priority
|
|
180
|
+
details = build_details(moderation_result)
|
|
181
|
+
session.enqueue_for_review!(
|
|
182
|
+
reason: "content_moderation",
|
|
183
|
+
priority: priority,
|
|
184
|
+
details: details
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
Result.new(
|
|
188
|
+
action: :flagged,
|
|
189
|
+
priority: priority,
|
|
190
|
+
details: details
|
|
191
|
+
)
|
|
192
|
+
else
|
|
193
|
+
Result.new(action: :passed)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def determine_priority(result)
|
|
198
|
+
# Check for critical categories first
|
|
199
|
+
if (result.flagged_categories & CRITICAL_CATEGORIES).any?
|
|
200
|
+
return :critical
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Check if explicitly flagged
|
|
204
|
+
if result.flagged?
|
|
205
|
+
max_score = result.category_scores.values.max || 0
|
|
206
|
+
return max_score >= THRESHOLDS[:critical] ? :critical : :high
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Check score thresholds even if not flagged
|
|
210
|
+
max_score = result.category_scores.values.max || 0
|
|
211
|
+
|
|
212
|
+
if max_score >= THRESHOLDS[:high]
|
|
213
|
+
:high
|
|
214
|
+
elsif max_score >= THRESHOLDS[:review]
|
|
215
|
+
:normal
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def build_details(result)
|
|
220
|
+
{
|
|
221
|
+
flagged: result.flagged?,
|
|
222
|
+
flagged_categories: result.flagged_categories,
|
|
223
|
+
highest_category: highest_category(result),
|
|
224
|
+
highest_score: result.category_scores.values.max&.round(4),
|
|
225
|
+
category_scores: result.category_scores.transform_values { |v| v.round(4) }
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def highest_category(result)
|
|
230
|
+
return nil if result.category_scores.empty?
|
|
231
|
+
|
|
232
|
+
result.category_scores.max_by { |_, score| score }&.first
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|