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.
- checksums.yaml +4 -4
- data/README.md +319 -1
- 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/annotations_controller.rb +2 -2
- data/app/controllers/observ/chats_controller.rb +1 -1
- data/app/controllers/observ/dataset_items_controller.rb +3 -3
- data/app/controllers/observ/dataset_runs_controller.rb +3 -3
- data/app/controllers/observ/datasets_controller.rb +4 -4
- data/app/controllers/observ/messages_controller.rb +5 -1
- data/app/controllers/observ/prompts_controller.rb +14 -6
- data/app/controllers/observ/review_queue_controller.rb +1 -1
- 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/dashboard_helper.rb +2 -2
- data/app/helpers/observ/markdown_helper.rb +29 -0
- data/app/helpers/observ/pagination_helper.rb +1 -1
- data/app/helpers/observ/prompts_helper.rb +48 -0
- data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
- data/app/models/concerns/observ/prompt_management.rb +10 -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/prompt.rb +2 -2
- data/app/models/observ/review_item.rb +1 -1
- data/app/models/observ/score.rb +1 -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/presenters/observ/agent_select_presenter.rb +3 -3
- data/app/services/observ/chat_instrumenter.rb +97 -7
- data/app/services/observ/concerns/observable_service.rb +108 -3
- data/app/services/observ/dataset_runner_service.rb +1 -1
- data/app/services/observ/embedding_instrumenter.rb +193 -0
- data/app/services/observ/evaluator_runner_service.rb +1 -1
- data/app/services/observ/evaluators/contains_evaluator.rb +1 -1
- data/app/services/observ/guardrail_service.rb +10 -1
- data/app/services/observ/image_generation_instrumenter.rb +243 -0
- data/app/services/observ/moderation_guardrail_service.rb +239 -0
- data/app/services/observ/moderation_instrumenter.rb +141 -0
- data/app/services/observ/prompt_manager/caching.rb +15 -2
- data/app/services/observ/transcription_instrumenter.rb +187 -0
- data/app/validators/observ/prompt_config_validator.rb +5 -5
- 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/config/routes.rb +13 -13
- data/db/migrate/005_create_observ_prompts.rb +2 -2
- data/db/migrate/011_create_observ_dataset_items.rb +1 -1
- data/db/migrate/012_create_observ_dataset_runs.rb +2 -2
- data/db/migrate/013_create_observ_dataset_run_items.rb +1 -1
- data/db/migrate/014_create_observ_scores.rb +2 -2
- data/db/migrate/015_refactor_scores_to_polymorphic.rb +2 -2
- data/db/migrate/016_create_observ_review_items.rb +2 -2
- data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
- data/lib/observ/engine.rb +7 -0
- data/lib/observ/version.rb +1 -1
- data/lib/tasks/observ_tasks.rake +2 -2
- 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
|
-
#
|
|
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: {
|
|
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
|
|
data/app/models/observ/prompt.rb
CHANGED
|
@@ -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
|
-
[
|
|
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: [
|
|
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: [
|
|
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") }
|
data/app/models/observ/score.rb
CHANGED
|
@@ -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: [
|
|
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
|
data/app/models/observ/trace.rb
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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| [
|
|
56
|
+
agents.map { |agent| [agent.display_name, agent.agent_identifier] }
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
end
|