rubyllm-observ 0.6.6 → 0.6.8

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +319 -1
  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/annotations_controller.rb +2 -2
  10. data/app/controllers/observ/chats_controller.rb +1 -1
  11. data/app/controllers/observ/dataset_items_controller.rb +3 -3
  12. data/app/controllers/observ/dataset_runs_controller.rb +3 -3
  13. data/app/controllers/observ/datasets_controller.rb +4 -4
  14. data/app/controllers/observ/messages_controller.rb +5 -1
  15. data/app/controllers/observ/prompts_controller.rb +14 -6
  16. data/app/controllers/observ/review_queue_controller.rb +1 -1
  17. data/app/controllers/observ/scores_controller.rb +1 -1
  18. data/app/controllers/observ/traces_controller.rb +1 -1
  19. data/app/helpers/observ/application_helper.rb +1 -0
  20. data/app/helpers/observ/dashboard_helper.rb +2 -2
  21. data/app/helpers/observ/markdown_helper.rb +29 -0
  22. data/app/helpers/observ/pagination_helper.rb +1 -1
  23. data/app/helpers/observ/prompts_helper.rb +48 -0
  24. data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
  25. data/app/models/concerns/observ/prompt_management.rb +10 -0
  26. data/app/models/observ/embedding.rb +45 -0
  27. data/app/models/observ/image_generation.rb +38 -0
  28. data/app/models/observ/moderation.rb +40 -0
  29. data/app/models/observ/null_prompt.rb +49 -2
  30. data/app/models/observ/observation.rb +3 -1
  31. data/app/models/observ/prompt.rb +2 -2
  32. data/app/models/observ/review_item.rb +1 -1
  33. data/app/models/observ/score.rb +1 -1
  34. data/app/models/observ/session.rb +33 -0
  35. data/app/models/observ/trace.rb +90 -4
  36. data/app/models/observ/transcription.rb +38 -0
  37. data/app/presenters/observ/agent_select_presenter.rb +3 -3
  38. data/app/services/observ/chat_instrumenter.rb +97 -7
  39. data/app/services/observ/concerns/observable_service.rb +108 -3
  40. data/app/services/observ/dataset_runner_service.rb +1 -1
  41. data/app/services/observ/embedding_instrumenter.rb +193 -0
  42. data/app/services/observ/evaluator_runner_service.rb +1 -1
  43. data/app/services/observ/evaluators/contains_evaluator.rb +1 -1
  44. data/app/services/observ/guardrail_service.rb +10 -1
  45. data/app/services/observ/image_generation_instrumenter.rb +243 -0
  46. data/app/services/observ/moderation_guardrail_service.rb +239 -0
  47. data/app/services/observ/moderation_instrumenter.rb +141 -0
  48. data/app/services/observ/prompt_manager/caching.rb +15 -2
  49. data/app/services/observ/transcription_instrumenter.rb +187 -0
  50. data/app/validators/observ/prompt_config_validator.rb +5 -5
  51. data/app/views/observ/chats/show.html.erb +9 -0
  52. data/app/views/observ/messages/_message.html.erb +1 -1
  53. data/app/views/observ/messages/create.turbo_stream.erb +1 -3
  54. data/app/views/observ/prompts/_config_editor.html.erb +115 -0
  55. data/app/views/observ/prompts/_form.html.erb +2 -13
  56. data/app/views/observ/prompts/_new_form.html.erb +2 -12
  57. data/config/routes.rb +13 -13
  58. data/db/migrate/005_create_observ_prompts.rb +2 -2
  59. data/db/migrate/011_create_observ_dataset_items.rb +1 -1
  60. data/db/migrate/012_create_observ_dataset_runs.rb +2 -2
  61. data/db/migrate/013_create_observ_dataset_run_items.rb +1 -1
  62. data/db/migrate/014_create_observ_scores.rb +2 -2
  63. data/db/migrate/015_refactor_scores_to_polymorphic.rb +2 -2
  64. data/db/migrate/016_create_observ_review_items.rb +2 -2
  65. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
  66. data/lib/observ/engine.rb +7 -0
  67. data/lib/observ/version.rb +1 -1
  68. data/lib/tasks/observ_tasks.rake +2 -2
  69. metadata +33 -3
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class ModerationGuardrailJob < ApplicationJob
5
+ queue_as :moderation
6
+
7
+ # Retry configuration
8
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
9
+ discard_on ActiveRecord::RecordNotFound
10
+
11
+ # Process a single trace or session
12
+ #
13
+ # @param trace_id [Integer] ID of the trace to moderate
14
+ # @param session_id [Integer] ID of the session to moderate
15
+ # @param options [Hash] Options for moderation
16
+ # @option options [Boolean] :moderate_input Whether to moderate input (default: true)
17
+ # @option options [Boolean] :moderate_output Whether to moderate output (default: true)
18
+ # @option options [Boolean] :aggregate Whether to moderate aggregated session content
19
+ def perform(trace_id: nil, session_id: nil, **options)
20
+ if trace_id
21
+ moderate_trace(trace_id, options)
22
+ elsif session_id
23
+ moderate_session(session_id, options)
24
+ else
25
+ Rails.logger.warn "[ModerationGuardrailJob] No trace_id or session_id provided"
26
+ end
27
+ end
28
+
29
+ # Class method to enqueue moderation for traces matching criteria
30
+ #
31
+ # @param scope [ActiveRecord::Relation] Scope of traces to moderate
32
+ # @param sample_percentage [Integer] Percentage of traces to sample (1-100)
33
+ def self.enqueue_for_scope(scope, sample_percentage: 100)
34
+ traces = scope.left_joins(:review_item)
35
+ .where(observ_review_items: { id: nil })
36
+
37
+ if sample_percentage < 100
38
+ sample_size = (traces.count * sample_percentage / 100.0).ceil
39
+ traces = traces.order("RANDOM()").limit(sample_size)
40
+ end
41
+
42
+ traces.find_each do |trace|
43
+ perform_later(trace_id: trace.id)
44
+ end
45
+ end
46
+
47
+ # Enqueue moderation for user-facing sessions only
48
+ #
49
+ # @param since [Time] Only process sessions created after this time
50
+ def self.enqueue_user_facing(since: 1.hour.ago)
51
+ Observ::Session
52
+ .where(created_at: since..)
53
+ .where("metadata->>'user_facing' = ?", "true")
54
+ .find_each do |session|
55
+ perform_later(session_id: session.id)
56
+ end
57
+ end
58
+
59
+ # Enqueue moderation for specific agent types
60
+ #
61
+ # @param agent_types [Array<String>] Agent types to moderate
62
+ # @param since [Time] Only process sessions created after this time
63
+ def self.enqueue_for_agent_types(agent_types, since: 1.hour.ago)
64
+ Observ::Session
65
+ .where(created_at: since..)
66
+ .where("metadata->>'agent_type' IN (?)", agent_types)
67
+ .find_each do |session|
68
+ perform_later(session_id: session.id)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def moderate_trace(trace_id, options)
75
+ trace = Observ::Trace.find(trace_id)
76
+
77
+ service = ModerationGuardrailService.new
78
+ result = service.evaluate_trace(
79
+ trace,
80
+ moderate_input: options.fetch(:moderate_input, true),
81
+ moderate_output: options.fetch(:moderate_output, true)
82
+ )
83
+
84
+ log_result("Trace #{trace_id}", result)
85
+ end
86
+
87
+ def moderate_session(session_id, options)
88
+ session = Observ::Session.find(session_id)
89
+
90
+ service = ModerationGuardrailService.new
91
+
92
+ if options[:aggregate]
93
+ # Moderate aggregated session content
94
+ result = service.evaluate_session_content(session)
95
+ log_result("Session #{session_id} (aggregated)", result)
96
+ else
97
+ # Moderate each trace individually
98
+ results = service.evaluate_session(session)
99
+ flagged_count = results.count(&:flagged?)
100
+ Rails.logger.info "[ModerationGuardrailJob] Session #{session_id}: #{flagged_count}/#{results.size} traces flagged"
101
+ end
102
+ end
103
+
104
+ def log_result(identifier, result)
105
+ case result.action
106
+ when :flagged
107
+ Rails.logger.info "[ModerationGuardrailJob] #{identifier} flagged (#{result.priority}): #{result.details[:flagged_categories]}"
108
+ when :skipped
109
+ Rails.logger.debug "[ModerationGuardrailJob] #{identifier} skipped: #{result.reason}"
110
+ when :passed
111
+ Rails.logger.debug "[ModerationGuardrailJob] #{identifier} passed moderation"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -135,6 +135,15 @@ module Observ
135
135
 
136
136
  # Override system_prompt to use prompt management
137
137
  def system_prompt
138
+ config = prompt_config.presence || {}
139
+ prompt_name = config[:prompt_name] || default_prompt_name
140
+ current_stamp = Observ::PromptManager.cache_stamp(name: prompt_name)
141
+
142
+ if defined?(@_prompt_cache_stamp) && @_prompt_cache_stamp != current_stamp
143
+ reset_prompt_cache!
144
+ end
145
+
146
+ @_prompt_cache_stamp = current_stamp
138
147
  @_system_prompt ||= fetch_prompt(variables: prompt_variables)
139
148
  end
140
149
 
@@ -159,6 +168,7 @@ module Observ
159
168
  def reset_prompt_cache!
160
169
  @_system_prompt = nil
161
170
  @_prompt_template = nil
171
+ @_prompt_cache_stamp = nil
162
172
  end
163
173
 
164
174
  # Override model to check prompt metadata first
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Embedding < Observation
5
+ # Set input texts for the embedding call
6
+ def set_input(texts)
7
+ update!(
8
+ input: texts.is_a?(Array) ? texts.to_json : texts
9
+ )
10
+ end
11
+
12
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
13
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
14
+
15
+ update!(
16
+ output: output.is_a?(String) ? output : output.to_json,
17
+ usage: merged_usage,
18
+ cost_usd: cost_usd,
19
+ end_time: Time.current,
20
+ status_message: status_message
21
+ )
22
+ end
23
+
24
+ # Embedding-specific helpers
25
+ def input_tokens
26
+ usage&.dig("input_tokens") || 0
27
+ end
28
+
29
+ def total_tokens
30
+ input_tokens # Embeddings only have input tokens
31
+ end
32
+
33
+ def batch_size
34
+ metadata&.dig("batch_size") || 1
35
+ end
36
+
37
+ def dimensions
38
+ metadata&.dig("dimensions")
39
+ end
40
+
41
+ def vectors_count
42
+ metadata&.dig("vectors_count") || 1
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class ImageGeneration < Observation
5
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
6
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
7
+
8
+ update!(
9
+ output: output.is_a?(String) ? output : output.to_json,
10
+ usage: merged_usage,
11
+ cost_usd: cost_usd,
12
+ end_time: Time.current,
13
+ status_message: status_message
14
+ )
15
+ end
16
+
17
+ # Image-specific helpers
18
+ def size
19
+ metadata&.dig("size")
20
+ end
21
+
22
+ def quality
23
+ metadata&.dig("quality")
24
+ end
25
+
26
+ def revised_prompt
27
+ metadata&.dig("revised_prompt")
28
+ end
29
+
30
+ def output_format
31
+ metadata&.dig("output_format") # "url" or "base64"
32
+ end
33
+
34
+ def mime_type
35
+ metadata&.dig("mime_type")
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Moderation < Observation
5
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
6
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
7
+
8
+ update!(
9
+ output: output.is_a?(String) ? output : output.to_json,
10
+ usage: merged_usage,
11
+ cost_usd: cost_usd,
12
+ end_time: Time.current,
13
+ status_message: status_message
14
+ )
15
+ end
16
+
17
+ # Moderation-specific helpers
18
+ def flagged?
19
+ metadata&.dig("flagged") || false
20
+ end
21
+
22
+ def categories
23
+ metadata&.dig("categories") || {}
24
+ end
25
+
26
+ def category_scores
27
+ metadata&.dig("category_scores") || {}
28
+ end
29
+
30
+ def flagged_categories
31
+ metadata&.dig("flagged_categories") || []
32
+ end
33
+
34
+ def highest_score_category
35
+ return nil if category_scores.empty?
36
+
37
+ category_scores.max_by { |_, score| score }&.first
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "mustache"
4
+
3
5
  module Observ
4
6
  # Null Object pattern for Prompt
5
7
  # Used when a prompt is not found, providing a fallback with the same interface
@@ -17,9 +19,36 @@ module Observ
17
19
  nil
18
20
  end
19
21
 
20
- # Returns the fallback text as-is (no variable compilation)
22
+ # Compile prompt with Mustache templating (same as Prompt)
21
23
  def compile(variables = {})
22
- @prompt
24
+ return @prompt if variables.empty?
25
+
26
+ Mustache.render(@prompt, variables)
27
+ end
28
+
29
+ # Compile with validation (raises if missing top-level variables)
30
+ # Note: Variables inside sections (loops) are validated at render time by Mustache
31
+ def compile_with_validation(variables = {})
32
+ required_vars = required_variables
33
+ provided_keys = variables.keys.map(&:to_s)
34
+
35
+ missing_vars = required_vars.reject do |var|
36
+ # Handle dot notation (e.g., "user.name" - check if "user" key exists)
37
+ root_key = var.split(".").first
38
+ provided_keys.include?(var) || provided_keys.include?(root_key)
39
+ end
40
+
41
+ if missing_vars.any?
42
+ raise Observ::VariableSubstitutionError, "Missing variables: #{missing_vars.join(', ')}"
43
+ end
44
+
45
+ compile(variables)
46
+ end
47
+
48
+ # Extract top-level variables from template (for validation purposes)
49
+ def required_variables
50
+ template_without_sections = strip_sections(@prompt)
51
+ template_without_sections.scan(/\{\{([^#\^\/!>\{\s][^}\s]*)\}\}/).flatten.uniq
23
52
  end
24
53
 
25
54
  # Null prompts are always in a "fallback" state
@@ -55,5 +84,23 @@ module Observ
55
84
  def inspect
56
85
  "#<Observ::NullPrompt name: #{name.inspect}, fallback: #{prompt[0..50].inspect}...>"
57
86
  end
87
+
88
+ private
89
+
90
+ # Strip section content from template for top-level variable extraction
91
+ # Removes content between {{#section}}...{{/section}} and {{^section}}...{{/section}}
92
+ def strip_sections(template)
93
+ result = template.dup
94
+
95
+ # Match sections: {{#name}}...{{/name}} or {{^name}}...{{/name}}
96
+ # Use non-greedy matching and handle nesting by repeating until stable
97
+ loop do
98
+ previous = result
99
+ result = result.gsub(/\{\{[#\^](\w+)\}\}.*?\{\{\/\1\}\}/m, "")
100
+ break if result == previous
101
+ end
102
+
103
+ result
104
+ end
58
105
  end
59
106
  end
@@ -9,7 +9,9 @@ module Observ
9
9
 
10
10
  validates :observation_id, presence: true, uniqueness: true
11
11
  validates :start_time, presence: true
12
- validates :type, presence: true, inclusion: { in: %w[Observ::Generation Observ::Span] }
12
+ validates :type, presence: true, inclusion: {
13
+ in: %w[Observ::Generation Observ::Span Observ::Embedding Observ::ImageGeneration Observ::Transcription Observ::Moderation]
14
+ }
13
15
 
14
16
  after_save :update_trace_metrics, if: :saved_change_to_cost_or_usage?
15
17
 
@@ -101,7 +101,7 @@ module Observ
101
101
 
102
102
  def self.clear_cache(name:)
103
103
  # Clear all cache keys for this prompt
104
- [ :draft, :production, :archived ].each do |state|
104
+ [:draft, :production, :archived].each do |state|
105
105
  Rails.cache.delete(cache_key_for(name: name, version: nil, state: state))
106
106
  end
107
107
  end
@@ -206,7 +206,7 @@ module Observ
206
206
 
207
207
  # Export
208
208
  def to_json_export
209
- as_json(except: [ :id, :created_at, :updated_at ])
209
+ as_json(except: [:id, :created_at, :updated_at])
210
210
  end
211
211
 
212
212
  def to_yaml_export
@@ -12,7 +12,7 @@ module Observ
12
12
  validates :reviewable, presence: true
13
13
  validates :reviewable_id, uniqueness: { scope: :reviewable_type }
14
14
 
15
- scope :actionable, -> { where(status: [ :pending, :in_progress ]) }
15
+ scope :actionable, -> { where(status: [:pending, :in_progress]) }
16
16
  scope :by_priority, -> { order(priority: :desc, created_at: :asc) }
17
17
  scope :sessions, -> { where(reviewable_type: "Observ::Session") }
18
18
  scope :traces, -> { where(reviewable_type: "Observ::Trace") }
@@ -13,7 +13,7 @@ module Observ
13
13
  validates :name, presence: true
14
14
  validates :value, presence: true, numericality: true
15
15
  validates :scoreable_id, uniqueness: {
16
- scope: [ :scoreable_type, :name, :source ],
16
+ scope: [:scoreable_type, :name, :source],
17
17
  message: "already has a score with this name and source"
18
18
  }
19
19
 
@@ -17,6 +17,7 @@ module Observ
17
17
 
18
18
  before_validation :set_session_id, on: :create
19
19
  before_validation :set_start_time, on: :create
20
+ after_commit :evaluate_guardrails, if: :just_completed?
20
21
 
21
22
  def create_trace(name: nil, input: nil, metadata: {}, tags: [])
22
23
  traces.create!(
@@ -103,6 +104,30 @@ module Observ
103
104
  instrumenter
104
105
  end
105
106
 
107
+ def instrument_embedding(context: {})
108
+ instrumenter = Observ::EmbeddingInstrumenter.new(self, context: context)
109
+ instrumenter.instrument!
110
+ instrumenter
111
+ end
112
+
113
+ def instrument_image_generation(context: {})
114
+ instrumenter = Observ::ImageGenerationInstrumenter.new(self, context: context)
115
+ instrumenter.instrument!
116
+ instrumenter
117
+ end
118
+
119
+ def instrument_transcription(context: {})
120
+ instrumenter = Observ::TranscriptionInstrumenter.new(self, context: context)
121
+ instrumenter.instrument!
122
+ instrumenter
123
+ end
124
+
125
+ def instrument_moderation(context: {})
126
+ instrumenter = Observ::ModerationInstrumenter.new(self, context: context)
127
+ instrumenter.instrument!
128
+ instrumenter
129
+ end
130
+
106
131
  def chat
107
132
  @chat ||= ::Chat.find_by(observability_session_id: session_id) if defined?(::Chat)
108
133
  end
@@ -131,5 +156,13 @@ module Observ
131
156
  ((g.end_time - g.start_time) * 1000).round(2)
132
157
  end || 0
133
158
  end
159
+
160
+ def just_completed?
161
+ previous_changes.key?("end_time") && end_time.present?
162
+ end
163
+
164
+ def evaluate_guardrails
165
+ GuardrailService.evaluate_session(self)
166
+ end
134
167
  end
135
168
  end
@@ -17,6 +17,7 @@ module Observ
17
17
  validates :start_time, presence: true
18
18
 
19
19
  after_save :update_session_metrics, if: :saved_change_to_total_cost_or_total_tokens?
20
+ after_commit :evaluate_guardrails, if: :just_completed?
20
21
 
21
22
  def create_generation(name: "llm_generation", model: nil, metadata: {}, **options)
22
23
  observations.create!(
@@ -42,6 +43,54 @@ module Observ
42
43
  )
43
44
  end
44
45
 
46
+ def create_embedding(name: "embedding", model: nil, metadata: {}, **options)
47
+ observations.create!(
48
+ observation_id: SecureRandom.uuid,
49
+ type: "Observ::Embedding",
50
+ name: name,
51
+ model: model,
52
+ metadata: metadata,
53
+ start_time: Time.current,
54
+ **options.slice(:model_parameters, :parent_observation_id)
55
+ )
56
+ end
57
+
58
+ def create_image_generation(name: "image_generation", model: nil, metadata: {}, **options)
59
+ observations.create!(
60
+ observation_id: SecureRandom.uuid,
61
+ type: "Observ::ImageGeneration",
62
+ name: name,
63
+ model: model,
64
+ metadata: metadata,
65
+ start_time: Time.current,
66
+ **options.slice(:model_parameters, :parent_observation_id)
67
+ )
68
+ end
69
+
70
+ def create_transcription(name: "transcription", model: nil, metadata: {}, **options)
71
+ observations.create!(
72
+ observation_id: SecureRandom.uuid,
73
+ type: "Observ::Transcription",
74
+ name: name,
75
+ model: model,
76
+ metadata: metadata,
77
+ start_time: Time.current,
78
+ **options.slice(:model_parameters, :parent_observation_id)
79
+ )
80
+ end
81
+
82
+ def create_moderation(name: "moderation", model: nil, metadata: {}, **options)
83
+ observations.create!(
84
+ observation_id: SecureRandom.uuid,
85
+ type: "Observ::Moderation",
86
+ name: name,
87
+ model: model,
88
+ metadata: metadata,
89
+ start_time: Time.current,
90
+ **options.slice(:model_parameters, :parent_observation_id)
91
+ )
92
+ end
93
+
45
94
  def finalize(output: nil, metadata: {})
46
95
  merged_metadata = (self.metadata || {}).merge(metadata)
47
96
  update!(
@@ -74,13 +123,26 @@ module Observ
74
123
  end
75
124
 
76
125
  def update_aggregated_metrics
77
- new_total_cost = generations.sum(:cost_usd) || 0.0
78
-
79
- # Database-agnostic token calculation
80
- new_total_tokens = generations.sum do |gen|
126
+ # Include generations, embeddings, image generations, transcriptions, and moderations in cost calculation
127
+ new_total_cost = (generations.sum(:cost_usd) || 0.0) +
128
+ (embeddings.sum(:cost_usd) || 0.0) +
129
+ (image_generations.sum(:cost_usd) || 0.0) +
130
+ (transcriptions.sum(:cost_usd) || 0.0) +
131
+ (moderations.sum(:cost_usd) || 0.0)
132
+
133
+ # Database-agnostic token calculation for generations
134
+ generation_tokens = generations.sum do |gen|
81
135
  gen.usage&.dig("total_tokens") || 0
82
136
  end
83
137
 
138
+ # Embeddings only have input tokens
139
+ embedding_tokens = embeddings.sum do |emb|
140
+ emb.usage&.dig("input_tokens") || 0
141
+ end
142
+
143
+ # Image generations, transcriptions, and moderations don't use tokens
144
+ new_total_tokens = generation_tokens + embedding_tokens
145
+
84
146
  update_columns(
85
147
  total_cost: new_total_cost,
86
148
  total_tokens: new_total_tokens
@@ -95,6 +157,22 @@ module Observ
95
157
  observations.where(type: "Observ::Span")
96
158
  end
97
159
 
160
+ def embeddings
161
+ observations.where(type: "Observ::Embedding")
162
+ end
163
+
164
+ def image_generations
165
+ observations.where(type: "Observ::ImageGeneration")
166
+ end
167
+
168
+ def transcriptions
169
+ observations.where(type: "Observ::Transcription")
170
+ end
171
+
172
+ def moderations
173
+ observations.where(type: "Observ::Moderation")
174
+ end
175
+
98
176
  def models_used
99
177
  generations.where.not(model: nil).distinct.pluck(:model)
100
178
  end
@@ -134,5 +212,13 @@ module Observ
134
212
  def update_session_metrics
135
213
  observ_session&.update_aggregated_metrics
136
214
  end
215
+
216
+ def just_completed?
217
+ previous_changes.key?("end_time") && end_time.present?
218
+ end
219
+
220
+ def evaluate_guardrails
221
+ GuardrailService.evaluate_trace(self)
222
+ end
137
223
  end
138
224
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class Transcription < Observation
5
+ def finalize(output:, usage: {}, cost_usd: 0.0, status_message: nil)
6
+ merged_usage = (self.usage || {}).merge(usage.stringify_keys)
7
+
8
+ update!(
9
+ output: output.is_a?(String) ? output : output.to_json,
10
+ usage: merged_usage,
11
+ cost_usd: cost_usd,
12
+ end_time: Time.current,
13
+ status_message: status_message
14
+ )
15
+ end
16
+
17
+ # Transcription-specific helpers
18
+ def audio_duration_s
19
+ metadata&.dig("audio_duration_s")
20
+ end
21
+
22
+ def language
23
+ metadata&.dig("language")
24
+ end
25
+
26
+ def segments_count
27
+ metadata&.dig("segments_count") || 0
28
+ end
29
+
30
+ def speakers_count
31
+ metadata&.dig("speakers_count")
32
+ end
33
+
34
+ def has_diarization?
35
+ metadata&.dig("has_diarization") || false
36
+ end
37
+ end
38
+ end
@@ -31,7 +31,7 @@ module Observ
31
31
  # Format: [[display_name, identifier], ...]
32
32
  # @return [Array<Array<String>>] options array for select dropdown
33
33
  def options
34
- [ default_option ] + agent_options
34
+ [default_option] + agent_options
35
35
  end
36
36
 
37
37
  # Convenience class method that injects agents from Observ::AgentProvider
@@ -47,13 +47,13 @@ module Observ
47
47
  # Default option for "no agent selected" state
48
48
  # @return [Array<String>] the default option
49
49
  def default_option
50
- [ "Default Agent", "" ]
50
+ ["Default Agent", ""]
51
51
  end
52
52
 
53
53
  # Maps agents to [display_name, identifier] pairs
54
54
  # @return [Array<Array<String>>] agent options
55
55
  def agent_options
56
- agents.map { |agent| [ agent.display_name, agent.agent_identifier ] }
56
+ agents.map { |agent| [agent.display_name, agent.agent_identifier] }
57
57
  end
58
58
  end
59
59
  end