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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +319 -2
  3. data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
  4. data/app/assets/javascripts/observ/controllers/index.js +29 -0
  5. data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
  6. data/app/assets/stylesheets/observ/_chat.scss +199 -0
  7. data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
  8. data/app/assets/stylesheets/observ/application.scss +1 -0
  9. data/app/controllers/observ/dataset_items_controller.rb +2 -2
  10. data/app/controllers/observ/dataset_runs_controller.rb +1 -1
  11. data/app/controllers/observ/datasets_controller.rb +2 -2
  12. data/app/controllers/observ/messages_controller.rb +5 -1
  13. data/app/controllers/observ/prompts_controller.rb +11 -3
  14. data/app/controllers/observ/scores_controller.rb +1 -1
  15. data/app/controllers/observ/traces_controller.rb +1 -1
  16. data/app/helpers/observ/application_helper.rb +1 -0
  17. data/app/helpers/observ/markdown_helper.rb +29 -0
  18. data/app/helpers/observ/prompts_helper.rb +48 -0
  19. data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
  20. data/app/models/observ/embedding.rb +45 -0
  21. data/app/models/observ/image_generation.rb +38 -0
  22. data/app/models/observ/moderation.rb +40 -0
  23. data/app/models/observ/null_prompt.rb +49 -2
  24. data/app/models/observ/observation.rb +3 -1
  25. data/app/models/observ/session.rb +33 -0
  26. data/app/models/observ/trace.rb +90 -4
  27. data/app/models/observ/transcription.rb +38 -0
  28. data/app/services/observ/chat_instrumenter.rb +96 -6
  29. data/app/services/observ/concerns/observable_service.rb +108 -3
  30. data/app/services/observ/embedding_instrumenter.rb +193 -0
  31. data/app/services/observ/guardrail_service.rb +9 -0
  32. data/app/services/observ/image_generation_instrumenter.rb +243 -0
  33. data/app/services/observ/moderation_guardrail_service.rb +235 -0
  34. data/app/services/observ/moderation_instrumenter.rb +141 -0
  35. data/app/services/observ/transcription_instrumenter.rb +187 -0
  36. data/app/views/layouts/observ/application.html.erb +1 -1
  37. data/app/views/observ/chats/show.html.erb +9 -0
  38. data/app/views/observ/messages/_message.html.erb +1 -1
  39. data/app/views/observ/messages/create.turbo_stream.erb +1 -3
  40. data/app/views/observ/prompts/_config_editor.html.erb +115 -0
  41. data/app/views/observ/prompts/_form.html.erb +2 -13
  42. data/app/views/observ/prompts/_new_form.html.erb +2 -12
  43. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
  44. data/lib/observ/configuration.rb +0 -2
  45. data/lib/observ/engine.rb +7 -0
  46. data/lib/observ/version.rb +1 -1
  47. 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