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,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Observ
4
+ class TranscriptionInstrumenter
5
+ attr_reader :session, :context
6
+
7
+ def initialize(session, context: {})
8
+ @session = session
9
+ @context = context
10
+ @original_transcribe_method = nil
11
+ @instrumented = false
12
+ end
13
+
14
+ def instrument!
15
+ return if @instrumented
16
+
17
+ wrap_transcribe_method
18
+ @instrumented = true
19
+
20
+ Rails.logger.info "[Observability] Instrumented RubyLLM.transcribe for session #{session.session_id}"
21
+ end
22
+
23
+ def uninstrument!
24
+ return unless @instrumented
25
+ return unless @original_transcribe_method
26
+
27
+ RubyLLM.define_singleton_method(:transcribe, @original_transcribe_method)
28
+ @instrumented = false
29
+
30
+ Rails.logger.info "[Observability] Uninstrumented RubyLLM.transcribe"
31
+ end
32
+
33
+ private
34
+
35
+ def wrap_transcribe_method
36
+ return if @original_transcribe_method
37
+
38
+ @original_transcribe_method = RubyLLM.method(:transcribe)
39
+ instrumenter = self
40
+
41
+ RubyLLM.define_singleton_method(:transcribe) do |*args, **kwargs|
42
+ instrumenter.send(:handle_transcribe_call, args, kwargs)
43
+ end
44
+ end
45
+
46
+ def handle_transcribe_call(args, kwargs)
47
+ audio_path = args[0]
48
+ model_id = kwargs[:model] || default_transcription_model
49
+ language = kwargs[:language]
50
+
51
+ trace = session.create_trace(
52
+ name: "transcription",
53
+ input: { audio_path: audio_path.to_s },
54
+ metadata: @context.merge(
55
+ model: model_id,
56
+ language: language
57
+ ).compact
58
+ )
59
+
60
+ transcription_obs = trace.create_transcription(
61
+ name: "transcribe",
62
+ model: model_id,
63
+ metadata: {
64
+ language: language,
65
+ has_diarization: kwargs[:speaker_names].present?
66
+ }.compact
67
+ )
68
+
69
+ result = @original_transcribe_method.call(*args, **kwargs)
70
+
71
+ finalize_transcription(transcription_obs, result)
72
+ trace.finalize(
73
+ output: format_output(result),
74
+ metadata: extract_trace_metadata(result)
75
+ )
76
+
77
+ result
78
+ rescue StandardError => e
79
+ handle_error(e, trace, transcription_obs)
80
+ raise
81
+ end
82
+
83
+ def finalize_transcription(transcription_obs, result)
84
+ cost = calculate_cost(result)
85
+
86
+ transcription_obs.finalize(
87
+ output: format_output(result),
88
+ usage: {},
89
+ cost_usd: cost
90
+ )
91
+
92
+ transcription_obs.update!(
93
+ input: result.text&.truncate(1000),
94
+ metadata: transcription_obs.metadata.merge(
95
+ audio_duration_s: result.duration,
96
+ language: result.respond_to?(:language) ? result.language : nil,
97
+ segments_count: result.segments&.count || 0,
98
+ speakers_count: extract_speakers_count(result),
99
+ has_diarization: has_diarization?(result)
100
+ ).compact
101
+ )
102
+ end
103
+
104
+ def calculate_cost(result)
105
+ model_id = result.model
106
+ return 0.0 unless model_id
107
+
108
+ model_info = RubyLLM.models.find(model_id)
109
+ return 0.0 unless model_info
110
+
111
+ duration_minutes = (result.duration || 0) / 60.0
112
+
113
+ # Transcription models typically use per-minute pricing
114
+ if model_info.respond_to?(:audio_price_per_minute) && model_info.audio_price_per_minute
115
+ (duration_minutes * model_info.audio_price_per_minute).round(6)
116
+ elsif model_info.respond_to?(:input_price_per_million) && model_info.input_price_per_million
117
+ # Fallback: some models might use token-based pricing
118
+ # Estimate ~150 tokens per minute of audio
119
+ estimated_tokens = duration_minutes * 150
120
+ (estimated_tokens * model_info.input_price_per_million / 1_000_000.0).round(6)
121
+ else
122
+ 0.0
123
+ end
124
+ rescue StandardError => e
125
+ Rails.logger.warn "[Observability] Failed to calculate transcription cost: #{e.message}"
126
+ 0.0
127
+ end
128
+
129
+ def extract_speakers_count(result)
130
+ return nil unless has_diarization?(result)
131
+ return nil unless result.segments
132
+
133
+ result.segments.map { |s| s.respond_to?(:speaker) ? s.speaker : nil }.compact.uniq.count
134
+ end
135
+
136
+ def has_diarization?(result)
137
+ return false unless result.segments&.any?
138
+
139
+ result.segments.first.respond_to?(:speaker)
140
+ end
141
+
142
+ def format_output(result)
143
+ {
144
+ model: result.model,
145
+ text_length: result.text&.length || 0,
146
+ duration_s: result.duration,
147
+ segments_count: result.segments&.count || 0
148
+ }.compact
149
+ end
150
+
151
+ def extract_trace_metadata(result)
152
+ {
153
+ audio_duration_s: result.duration,
154
+ language: result.respond_to?(:language) ? result.language : nil
155
+ }.compact
156
+ end
157
+
158
+ def default_transcription_model
159
+ if RubyLLM.config.respond_to?(:default_transcription_model)
160
+ RubyLLM.config.default_transcription_model
161
+ else
162
+ "whisper-1"
163
+ end
164
+ end
165
+
166
+ def handle_error(error, trace, transcription_obs)
167
+ return unless trace
168
+
169
+ error_span = trace.create_span(
170
+ name: "error",
171
+ metadata: {
172
+ error_type: error.class.name,
173
+ level: "ERROR"
174
+ },
175
+ input: {
176
+ error_message: error.message,
177
+ backtrace: error.backtrace&.first(10)
178
+ }.to_json
179
+ )
180
+ error_span.finalize(output: { error_captured: true }.to_json)
181
+
182
+ transcription_obs&.update(status_message: "FAILED") rescue nil
183
+
184
+ Rails.logger.error "[Observability] Transcription error captured: #{error.class.name} - #{error.message}"
185
+ end
186
+ end
187
+ end
@@ -77,7 +77,7 @@ module Observ
77
77
  @errors << "#{key} must be a string"
78
78
  end
79
79
  when :boolean
80
- unless [ true, false ].include?(value)
80
+ unless [true, false].include?(value)
81
81
  @errors << "#{key} must be a boolean"
82
82
  end
83
83
  when :array
@@ -133,7 +133,7 @@ module Observ
133
133
  end
134
134
 
135
135
  def validate_unknown_keys
136
- schema_keys = schema.keys.map { |k| [ k.to_s, k.to_sym ] }.flatten
136
+ schema_keys = schema.keys.map { |k| [k.to_s, k.to_sym] }.flatten
137
137
  config_keys = config.keys
138
138
 
139
139
  unknown_keys = config_keys - schema_keys
@@ -153,11 +153,11 @@ module Observ
153
153
 
154
154
  def value_with_key(key)
155
155
  if config.key?(key.to_s)
156
- [ config[key.to_s], key.to_s ]
156
+ [config[key.to_s], key.to_s]
157
157
  elsif config.key?(key.to_sym)
158
- [ config[key.to_sym], key.to_sym ]
158
+ [config[key.to_sym], key.to_sym]
159
159
  else
160
- [ nil, nil ]
160
+ [nil, nil]
161
161
  end
162
162
  end
163
163
 
@@ -19,6 +19,15 @@
19
19
  <%= render message %>
20
20
  <% end %>
21
21
  </div>
22
+
23
+ <div id="typing-indicator" class="observ-typing-indicator" style="display: none;">
24
+ <div class="observ-typing-indicator__dots">
25
+ <span></span>
26
+ <span></span>
27
+ <span></span>
28
+ </div>
29
+ <span class="observ-typing-indicator__text">AI is thinking...</span>
30
+ </div>
22
31
 
23
32
  <div class="observ-form-separator">
24
33
  <%= render "observ/messages/form", chat: @chat, message: @message %>
@@ -5,7 +5,7 @@
5
5
  </div>
6
6
 
7
7
  <div id="message_<%= message.id %>_content" class="observ-chat-message__content">
8
- <%= simple_format(message.content) %>
8
+ <%= render_markdown(message.content) %>
9
9
  </div>
10
10
 
11
11
  <% if message.tool_call? %>
@@ -1,7 +1,5 @@
1
1
  <%= turbo_stream.append "messages" do %>
2
- <% @chat.messages.last(2).each do |message| %>
3
- <%= render message %>
4
- <% end %>
2
+ <%= render @message %>
5
3
  <% end %>
6
4
 
7
5
  <%= turbo_stream.replace "new_message" do %>
@@ -0,0 +1,115 @@
1
+ <%#
2
+ Config Editor Partial
3
+
4
+ Provides a hybrid interface for editing prompt configuration:
5
+ - Structured fields for common settings (model, temperature, max_tokens)
6
+ - Collapsible advanced section for raw JSON editing
7
+
8
+ Local variables:
9
+ - prompt: The Observ::Prompt record or PromptForm being edited
10
+ - f: The form builder
11
+ %>
12
+
13
+ <% config = prompt_config_hash(prompt) %>
14
+ <% config_json = config.present? ? JSON.pretty_generate(config) : "" %>
15
+
16
+ <div class="observ-config-editor"
17
+ data-controller="observ--config-editor"
18
+ data-observ--config-editor-known-keys-value='["model", "temperature", "max_tokens"]'>
19
+
20
+ <!-- Common Settings -->
21
+ <fieldset class="observ-config-editor__fieldset">
22
+ <legend class="observ-config-editor__legend">Model Settings</legend>
23
+
24
+ <!-- Model Select -->
25
+ <div class="observ-config-editor__row">
26
+ <label class="observ-form__label" for="config_model">Model</label>
27
+ <select id="config_model"
28
+ class="observ-form__select"
29
+ data-observ--config-editor-target="model"
30
+ data-action="change->observ--config-editor#syncToJson">
31
+ <option value="">-- Select a model (optional) --</option>
32
+ <% chat_model_options_grouped.each do |provider, models| %>
33
+ <optgroup label="<%= provider %>">
34
+ <% models.each do |display_name, model_id| %>
35
+ <option value="<%= model_id %>" <%= 'selected' if config_value(prompt, :model) == model_id %>>
36
+ <%= display_name %>
37
+ </option>
38
+ <% end %>
39
+ </optgroup>
40
+ <% end %>
41
+ </select>
42
+ <p class="observ-form__hint">The LLM model to use for this prompt</p>
43
+ </div>
44
+
45
+ <!-- Temperature -->
46
+ <div class="observ-config-editor__row">
47
+ <label class="observ-form__label" for="config_temperature">Temperature</label>
48
+ <div class="observ-config-editor__input-group">
49
+ <input type="number"
50
+ id="config_temperature"
51
+ class="observ-form__input observ-config-editor__number-input"
52
+ min="0"
53
+ max="2"
54
+ step="0.1"
55
+ placeholder="0.7"
56
+ value="<%= config_value(prompt, :temperature) %>"
57
+ data-observ--config-editor-target="temperature"
58
+ data-action="input->observ--config-editor#syncToJson">
59
+ <span class="observ-config-editor__range-hint">0.0 - 2.0</span>
60
+ </div>
61
+ <p class="observ-form__hint">
62
+ Controls randomness: 0.0 = deterministic, 1.0 = balanced, 2.0 = creative
63
+ </p>
64
+ </div>
65
+
66
+ <!-- Max Tokens -->
67
+ <div class="observ-config-editor__row">
68
+ <label class="observ-form__label" for="config_max_tokens">Max Tokens</label>
69
+ <div class="observ-config-editor__input-group">
70
+ <input type="number"
71
+ id="config_max_tokens"
72
+ class="observ-form__input observ-config-editor__number-input"
73
+ min="1"
74
+ max="128000"
75
+ placeholder="2000"
76
+ value="<%= config_value(prompt, :max_tokens) %>"
77
+ data-observ--config-editor-target="maxTokens"
78
+ data-action="input->observ--config-editor#syncToJson">
79
+ </div>
80
+ <p class="observ-form__hint">
81
+ Maximum number of tokens in the response
82
+ </p>
83
+ </div>
84
+ </fieldset>
85
+
86
+ <!-- Advanced JSON Section (Collapsible) -->
87
+ <details class="observ-config-editor__advanced">
88
+ <summary class="observ-config-editor__advanced-summary">
89
+ Advanced Configuration (JSON)
90
+ </summary>
91
+
92
+ <div class="observ-config-editor__advanced-content">
93
+ <p class="observ-form__hint">
94
+ Edit the raw JSON configuration. Changes here will sync with the fields above.
95
+ You can add custom parameters not available in the structured fields.
96
+ </p>
97
+
98
+ <textarea class="observ-form__textarea observ-form__textarea--code"
99
+ rows="8"
100
+ placeholder='{"model": "gpt-4o", "temperature": 0.7, "max_tokens": 2000}'
101
+ data-observ--config-editor-target="jsonInput"
102
+ data-action="input->observ--config-editor#syncFromJson"><%= config_json %></textarea>
103
+
104
+ <!-- Validation Status -->
105
+ <div class="observ-config-editor__status"
106
+ data-observ--config-editor-target="status">
107
+ </div>
108
+ </div>
109
+ </details>
110
+
111
+ <!-- Hidden field for form submission -->
112
+ <%= f.hidden_field :config,
113
+ value: config_json,
114
+ data: { "observ--config-editor-target": "hiddenField" } %>
115
+ </div>
@@ -66,19 +66,8 @@
66
66
  </div>
67
67
  </div>
68
68
 
69
- <!-- Configuration (JSON) -->
70
- <div class="observ-form__group">
71
- <%= f.label :config, "Configuration (JSON)", class: "observ-form__label" %>
72
- <%= f.text_area :config,
73
- value: prompt.config.present? ? JSON.pretty_generate(prompt.config) : "",
74
- rows: 8,
75
- placeholder: '{\n "model": "gpt-4o",\n "temperature": 0.7,\n "max_tokens": 2000\n}',
76
- class: "observ-form__textarea observ-form__textarea--code",
77
- data: { controller: "json-editor" } %>
78
- <p class="observ-form__hint">
79
- Optional JSON configuration for model parameters and metadata
80
- </p>
81
- </div>
69
+ <!-- Configuration -->
70
+ <%= render 'observ/prompts/config_editor', prompt: prompt, f: f %>
82
71
 
83
72
  <!-- Actions -->
84
73
  <div class="observ-form__actions observ-form__actions--between">
@@ -60,18 +60,8 @@
60
60
  </div>
61
61
  </div>
62
62
 
63
- <!-- Configuration (JSON) -->
64
- <div class="observ-form__group">
65
- <%= f.label :config, "Configuration (JSON)", class: "observ-form__label" %>
66
- <%= f.text_area :config,
67
- rows: 8,
68
- placeholder: '{\n "model": "gpt-4o",\n "temperature": 0.7,\n "max_tokens": 2000\n}',
69
- class: "observ-form__textarea observ-form__textarea--code",
70
- data: { controller: "json-editor" } %>
71
- <p class="observ-form__hint">
72
- Optional JSON configuration for model parameters and metadata
73
- </p>
74
- </div>
63
+ <!-- Configuration -->
64
+ <%= render 'observ/prompts/config_editor', prompt: form, f: f %>
75
65
 
76
66
  <!-- Hidden field for from_version -->
77
67
  <%= f.hidden_field :from_version %>
data/config/routes.rb CHANGED
@@ -7,22 +7,22 @@ Observ::Engine.routes.draw do
7
7
 
8
8
  # Chat routes - only available if Chat model exists in host app
9
9
  if defined?(::Chat) && ::Chat.respond_to?(:acts_as_chat)
10
- resources :chats, only: [ :index, :new, :create, :show ] do
11
- resources :messages, only: [ :create ]
10
+ resources :chats, only: [:index, :new, :create, :show] do
11
+ resources :messages, only: [:create]
12
12
  end
13
13
  end
14
14
 
15
- resources :sessions, only: [ :index, :show ] do
15
+ resources :sessions, only: [:index, :show] do
16
16
  member do
17
17
  get :metrics
18
18
  get :drawer_test
19
19
  get :annotations_drawer
20
20
  end
21
- resources :annotations, only: [ :index, :create, :destroy ]
22
- resources :scores, only: [ :create, :destroy ]
21
+ resources :annotations, only: [:index, :create, :destroy]
22
+ resources :scores, only: [:create, :destroy]
23
23
  end
24
24
 
25
- resources :traces, only: [ :index, :show ] do
25
+ resources :traces, only: [:index, :show] do
26
26
  collection do
27
27
  get :search
28
28
  end
@@ -32,11 +32,11 @@ Observ::Engine.routes.draw do
32
32
  get :add_to_dataset_drawer
33
33
  post :add_to_dataset
34
34
  end
35
- resources :annotations, only: [ :index, :create, :destroy ]
36
- resources :scores, only: [ :create, :destroy ]
35
+ resources :annotations, only: [:index, :create, :destroy]
36
+ resources :scores, only: [:create, :destroy]
37
37
  end
38
38
 
39
- resources :observations, only: [ :index, :show ] do
39
+ resources :observations, only: [:index, :show] do
40
40
  collection do
41
41
  get :generations
42
42
  get :spans
@@ -47,7 +47,7 @@ Observ::Engine.routes.draw do
47
47
  get "annotations/traces", to: "annotations#traces_index", as: :traces_annotations
48
48
  get "annotations/export", to: "annotations#export", as: :export_annotations
49
49
 
50
- resources :reviews, only: [ :index, :show ], controller: "review_queue" do
50
+ resources :reviews, only: [:index, :show], controller: "review_queue" do
51
51
  collection do
52
52
  get :sessions
53
53
  get :traces
@@ -65,7 +65,7 @@ Observ::Engine.routes.draw do
65
65
  get :compare # Compare versions
66
66
  end
67
67
 
68
- resources :versions, only: [ :show ], controller: "prompt_versions" do
68
+ resources :versions, only: [:show], controller: "prompt_versions" do
69
69
  member do
70
70
  post :promote # draft -> production
71
71
  post :demote # production -> archived
@@ -76,8 +76,8 @@ Observ::Engine.routes.draw do
76
76
  end
77
77
 
78
78
  resources :datasets do
79
- resources :items, controller: "dataset_items", except: [ :show ]
80
- resources :runs, controller: "dataset_runs", only: [ :index, :show, :new, :create, :destroy ] do
79
+ resources :items, controller: "dataset_items", except: [:show]
80
+ resources :runs, controller: "dataset_runs", only: [:index, :show, :new, :create, :destroy] do
81
81
  member do
82
82
  post :run_evaluators
83
83
  get :review
@@ -12,10 +12,10 @@ class CreateObservPrompts < ActiveRecord::Migration[7.0]
12
12
  t.timestamps
13
13
 
14
14
  # Composite unique index for name + version
15
- t.index [ :name, :version ], unique: true
15
+ t.index [:name, :version], unique: true
16
16
 
17
17
  # Index for state queries (e.g., find all production prompts)
18
- t.index [ :name, :state ]
18
+ t.index [:name, :state]
19
19
  end
20
20
  end
21
21
  end
@@ -11,7 +11,7 @@ class CreateObservDatasetItems < ActiveRecord::Migration[7.0]
11
11
  t.references :source_trace, foreign_key: { to_table: :observ_traces }
12
12
  t.timestamps
13
13
 
14
- t.index [ :dataset_id, :status ]
14
+ t.index [:dataset_id, :status]
15
15
  end
16
16
  end
17
17
  end
@@ -15,8 +15,8 @@ class CreateObservDatasetRuns < ActiveRecord::Migration[7.0]
15
15
  t.integer :total_tokens, default: 0
16
16
  t.timestamps
17
17
 
18
- t.index [ :dataset_id, :name ], unique: true
19
- t.index [ :dataset_id, :status ]
18
+ t.index [:dataset_id, :name], unique: true
19
+ t.index [:dataset_id, :status]
20
20
  end
21
21
  end
22
22
  end
@@ -10,7 +10,7 @@ class CreateObservDatasetRunItems < ActiveRecord::Migration[7.0]
10
10
  t.text :error
11
11
  t.timestamps
12
12
 
13
- t.index [ :dataset_run_id, :dataset_item_id ], unique: true, name: "idx_run_items_on_run_and_item"
13
+ t.index [:dataset_run_id, :dataset_item_id], unique: true, name: "idx_run_items_on_run_and_item"
14
14
  end
15
15
  end
16
16
  end
@@ -18,8 +18,8 @@ class CreateObservScores < ActiveRecord::Migration[7.0]
18
18
 
19
19
  t.timestamps
20
20
 
21
- t.index [ :dataset_run_item_id, :name, :source ], unique: true, name: "idx_scores_on_run_item_name_source"
22
- t.index [ :trace_id, :name ]
21
+ t.index [:dataset_run_item_id, :name, :source], unique: true, name: "idx_scores_on_run_item_name_source"
22
+ t.index [:trace_id, :name]
23
23
  t.index :name
24
24
  end
25
25
  end
@@ -19,8 +19,8 @@ class RefactorScoresToPolymorphic < ActiveRecord::Migration[7.0]
19
19
  remove_column :observ_scores, :trace_id, :bigint
20
20
 
21
21
  # Add new indexes
22
- add_index :observ_scores, [ :scoreable_type, :scoreable_id ]
23
- add_index :observ_scores, [ :scoreable_type, :scoreable_id, :name, :source ],
22
+ add_index :observ_scores, [:scoreable_type, :scoreable_id]
23
+ add_index :observ_scores, [:scoreable_type, :scoreable_id, :name, :source],
24
24
  unique: true,
25
25
  name: "idx_scores_unique_on_scoreable_name_source"
26
26
  end
@@ -17,8 +17,8 @@ class CreateObservReviewItems < ActiveRecord::Migration[7.0]
17
17
 
18
18
  t.timestamps
19
19
 
20
- t.index [ :reviewable_type, :reviewable_id ], unique: true, name: "idx_review_items_on_reviewable"
21
- t.index [ :status, :priority, :created_at ], name: "idx_review_items_on_status_priority_created"
20
+ t.index [:reviewable_type, :reviewable_id], unique: true, name: "idx_review_items_on_reviewable"
21
+ t.index [:status, :priority, :created_at], name: "idx_review_items_on_status_priority_created"
22
22
  t.index :status
23
23
  end
24
24
  end
@@ -1,8 +1,11 @@
1
1
  class ChatResponseJob < ApplicationJob
2
2
  retry_on RubyLLM::BadRequestError, wait: 2.seconds, attempts: 1
3
3
 
4
- def perform(chat_id, content)
4
+ # @param chat_id [Integer] The chat ID
5
+ # @param user_message_id [Integer] The ID of the user message (already created by the controller)
6
+ def perform(chat_id, user_message_id)
5
7
  chat = Chat.find(chat_id)
8
+ user_message = chat.messages.find(user_message_id)
6
9
 
7
10
  # Observability is automatically enabled via after_find callback
8
11
  # All LLM calls, tool calls, and metrics are tracked automatically
@@ -12,7 +15,10 @@ class ChatResponseJob < ApplicationJob
12
15
  begin
13
16
  # Model parameters (temperature, max_tokens, etc.) are automatically configured
14
17
  # via the initialize_agent callback when the chat is created
15
- chat.ask(content) do |chunk|
18
+ #
19
+ # Use complete instead of ask to avoid creating a duplicate user message.
20
+ # The user message was already created by the controller for immediate UI feedback.
21
+ chat.complete do |chunk|
16
22
  if chunk.content && !chunk.content.blank?
17
23
  message = chat.messages.last
18
24
  message.broadcast_append_chunk(chunk.content)
@@ -23,7 +29,7 @@ class ChatResponseJob < ApplicationJob
23
29
 
24
30
  error_message = chat.messages.create!(
25
31
  role: :assistant,
26
- content: "I apologize, but I encountered an error while processing your request. This might be due to a tool call issue. Please try rephrasing your question or try again."
32
+ content: "**Error:** #{e.message}"
27
33
  )
28
34
 
29
35
  error_message.broadcast_replace_to(
data/lib/observ/engine.rb CHANGED
@@ -8,6 +8,13 @@ module Observ
8
8
  g.factory_bot dir: "spec/factories"
9
9
  end
10
10
 
11
+ # Make helpers available to host app for Turbo broadcasts
12
+ initializer "observ.helpers" do
13
+ ActiveSupport.on_load(:action_controller_base) do
14
+ helper Observ::MarkdownHelper
15
+ end
16
+ end
17
+
11
18
  # Make concerns available to host app
12
19
  initializer "observ.load_concerns" do
13
20
  config.to_prepare do
@@ -1,3 +1,3 @@
1
1
  module Observ
2
- VERSION = "0.6.6"
2
+ VERSION = "0.6.8"
3
3
  end
@@ -11,7 +11,7 @@ namespace :observ do
11
11
  rails observ:sync_assets[app/javascript/stylesheets/observ]
12
12
  rails observ:sync_assets[app/assets/stylesheets/observ,app/javascript/controllers/observ]
13
13
  "
14
- task :sync_assets, [ :styles_dest, :js_dest ] => :environment do |t, args|
14
+ task :sync_assets, [:styles_dest, :js_dest] => :environment do |t, args|
15
15
  require "observ/asset_installer"
16
16
 
17
17
  # Get the observ gem root (this task is in observ/lib/tasks)
@@ -46,7 +46,7 @@ namespace :observ do
46
46
  rails observ:install_assets[app/javascript/stylesheets/observ]
47
47
  rails observ:install_assets[app/assets/stylesheets/observ,app/javascript/controllers/observ]
48
48
  "
49
- task :install_assets, [ :styles_dest, :js_dest ] => :environment do |t, args|
49
+ task :install_assets, [:styles_dest, :js_dest] => :environment do |t, args|
50
50
  require "observ/asset_installer"
51
51
 
52
52
  # Get the observ gem root