ruby_llm-agents 0.3.3 → 0.3.4

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +68 -4
  3. data/app/models/ruby_llm/agents/execution/analytics.rb +114 -13
  4. data/app/models/ruby_llm/agents/execution.rb +19 -58
  5. data/app/views/layouts/rubyllm/agents/application.html.erb +92 -350
  6. data/app/views/rubyllm/agents/agents/show.html.erb +331 -385
  7. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +46 -0
  8. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -90
  9. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +79 -5
  10. data/app/views/rubyllm/agents/dashboard/_top_errors.html.erb +49 -0
  11. data/app/views/rubyllm/agents/dashboard/index.html.erb +76 -121
  12. data/app/views/rubyllm/agents/executions/show.html.erb +134 -85
  13. data/app/views/rubyllm/agents/settings/show.html.erb +1 -1
  14. data/app/views/rubyllm/agents/shared/_breadcrumbs.html.erb +48 -0
  15. data/app/views/rubyllm/agents/shared/_nav_link.html.erb +27 -0
  16. data/config/routes.rb +2 -0
  17. data/lib/ruby_llm/agents/base/caching.rb +43 -0
  18. data/lib/ruby_llm/agents/base/cost_calculation.rb +103 -0
  19. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  20. data/lib/ruby_llm/agents/base/execution.rb +206 -0
  21. data/lib/ruby_llm/agents/base/reliability_execution.rb +131 -0
  22. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  23. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  24. data/lib/ruby_llm/agents/base.rb +15 -805
  25. data/lib/ruby_llm/agents/version.rb +1 -1
  26. metadata +12 -20
  27. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  28. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  29. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  30. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  31. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 26b04f0b8d00a9c87b5555beccacbfc83208be0a370b0fcc6e7ee518c2543282
4
- data.tar.gz: 51a1d674af7e489ad6020eb324b13b6509137e035c45bb53d140ca22574fc187
3
+ metadata.gz: 26167fa0299a5be7e45f5742829e8d30b6b5eb6c96ce341af7895358897b5e88
4
+ data.tar.gz: ea883ffe277ac2595b2866cdc427a95876bf0fcb4f3b6f5d9678b7c1e54279cb
5
5
  SHA512:
6
- metadata.gz: 69635d4258f0082742d1b899e26e21019020e58f904df3a20833c7d7035dc39f139a4d07568a598735b4071e694dff48cf7ca67b37f04ae3f6341d9c32e30f8c
7
- data.tar.gz: f655a50e12ce7117f34bf7a1d0cbc3c8b7168233c6fd4ebcd63e5678de92b0d5fad61152f4001a173b201673dcd87380f4a22faa9b6d542210428e3896b7482d
6
+ metadata.gz: 8d2f5c7e95d86da7c22ec97e90b205ec2b61918cf5d9c7fedcfca86275515d76409429ea4bdb5fe524dd58ae4691ef7d5602edee6233d77ad243a1e1ae9778e6
7
+ data.tar.gz: b32a708e9576ee8378a298febd4657cf72cccac481e5b31e929ef160c71be45a32a02093be06d7d62403cc03f08bb48465c8a217cd3f808f406149fa4ba6a92a
@@ -14,25 +14,89 @@ module RubyLLM
14
14
  # Renders the main dashboard view
15
15
  #
16
16
  # Loads now strip data, critical alerts, hourly activity,
17
- # and recent executions for real-time monitoring.
17
+ # recent executions, agent comparison, and top errors.
18
18
  #
19
19
  # @return [void]
20
20
  def index
21
- @now_strip = Execution.now_strip_data
21
+ @selected_range = params[:range].presence || "today"
22
+ @days = range_to_days(@selected_range)
23
+ @now_strip = Execution.now_strip_data(range: @selected_range)
22
24
  @critical_alerts = load_critical_alerts
23
25
  @hourly_activity = Execution.hourly_activity_chart
24
26
  @recent_executions = Execution.recent(10)
27
+ @agent_stats = build_agent_comparison
28
+ @top_errors = build_top_errors
25
29
  end
26
30
 
27
31
  # Returns chart data as JSON for live updates
28
32
  #
29
- # @return [JSON] Chart data with categories and series
33
+ # @param range [String] Time range: "today", "7d", or "30d"
34
+ # @return [JSON] Chart data with series
30
35
  def chart_data
31
- render json: Execution.hourly_activity_chart_json
36
+ range = params[:range].presence || "today"
37
+ render json: Execution.activity_chart_json(range: range)
32
38
  end
33
39
 
34
40
  private
35
41
 
42
+ # Converts range parameter to number of days
43
+ #
44
+ # @param range [String] Range parameter (today, 7d, 30d)
45
+ # @return [Integer] Number of days
46
+ def range_to_days(range)
47
+ case range
48
+ when "today" then 1
49
+ when "7d" then 7
50
+ when "30d" then 30
51
+ else 1
52
+ end
53
+ end
54
+
55
+ # Builds per-agent comparison statistics
56
+ #
57
+ # @return [Array<Hash>] Array of agent stats sorted by cost descending
58
+ def build_agent_comparison
59
+ scope = Execution.last_n_days(@days)
60
+ agent_types = scope.distinct.pluck(:agent_type)
61
+
62
+ agent_types.map do |agent_type|
63
+ agent_scope = scope.where(agent_type: agent_type)
64
+ count = agent_scope.count
65
+ total_cost = agent_scope.sum(:total_cost) || 0
66
+ successful = agent_scope.successful.count
67
+
68
+ {
69
+ agent_type: agent_type,
70
+ executions: count,
71
+ total_cost: total_cost,
72
+ avg_cost: count > 0 ? (total_cost / count).round(6) : 0,
73
+ avg_duration_ms: agent_scope.average(:duration_ms)&.round || 0,
74
+ success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0
75
+ }
76
+ end.sort_by { |a| -(a[:total_cost] || 0) }
77
+ end
78
+
79
+ # Builds top errors list
80
+ #
81
+ # @return [Array<Hash>] Top 5 error classes with counts
82
+ def build_top_errors
83
+ scope = Execution.last_n_days(@days).where(status: "error")
84
+ total_errors = scope.count
85
+
86
+ scope.group(:error_class)
87
+ .select("error_class, COUNT(*) as count, MAX(created_at) as last_seen")
88
+ .order("count DESC")
89
+ .limit(5)
90
+ .map do |row|
91
+ {
92
+ error_class: row.error_class || "Unknown Error",
93
+ count: row.count,
94
+ percentage: total_errors > 0 ? (row.count.to_f / total_errors * 100).round(1) : 0,
95
+ last_seen: row.last_seen
96
+ }
97
+ end
98
+ end
99
+
36
100
  # Fetches cached daily statistics for the dashboard
37
101
  #
38
102
  # Results are cached for 1 minute to reduce database load while
@@ -167,35 +167,136 @@ module RubyLLM
167
167
  end
168
168
 
169
169
  # Returns chart data as arrays for Highcharts live updates
170
- # Format: { categories: [...], series: [...] }
170
+ # Format: { categories: [...], series: [...], range: ... }
171
+ #
172
+ # @param range [String] Time range: "today" (hourly), "7d" or "30d" (daily)
173
+ def activity_chart_json(range: "today")
174
+ case range
175
+ when "7d"
176
+ build_daily_chart_data(7)
177
+ when "30d"
178
+ build_daily_chart_data(30)
179
+ else
180
+ build_hourly_chart_data
181
+ end
182
+ end
183
+
184
+ # Alias for backwards compatibility
171
185
  def hourly_activity_chart_json
172
- # Always use current time as reference so chart shows "now" on the right
173
- reference_time = Time.current.beginning_of_hour
186
+ activity_chart_json(range: "today")
187
+ end
174
188
 
175
- categories = []
189
+ private
190
+
191
+ # Builds hourly chart data for last 24 hours
192
+ # Optimized: Single GROUP BY query instead of 72 individual queries
193
+ def build_hourly_chart_data
194
+ reference_time = Time.current.beginning_of_hour
195
+ start_time = reference_time - 23.hours
196
+
197
+ # Single query with GROUP BY - reduces 72 queries to 1
198
+ results = where(created_at: start_time..(reference_time + 1.hour))
199
+ .group(Arel.sql("DATE_TRUNC('hour', created_at)"))
200
+ .select(
201
+ Arel.sql("DATE_TRUNC('hour', created_at) as time_bucket"),
202
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'success') as success_count"),
203
+ Arel.sql("COUNT(*) FILTER (WHERE status IN ('error', 'timeout')) as failed_count"),
204
+ Arel.sql("COALESCE(SUM(total_cost), 0) as total_cost")
205
+ )
206
+ .index_by { |r| r.time_bucket.to_time.beginning_of_hour }
207
+
208
+ # Build arrays for all 24 hours (fill missing with zeros)
176
209
  success_data = []
177
210
  failed_data = []
211
+ cost_data = []
212
+ total_success = 0
213
+ total_failed = 0
214
+ total_cost = 0.0
178
215
 
179
- # Create entries for the last 24 hours ending at current hour
180
216
  (23.downto(0)).each do |hours_ago|
181
- start_time = reference_time - hours_ago.hours
182
- end_time = start_time + 1.hour
183
- categories << start_time.in_time_zone.strftime("%H:%M")
217
+ bucket_time = (reference_time - hours_ago.hours).beginning_of_hour
218
+ row = results[bucket_time]
184
219
 
185
- hour_scope = where(created_at: start_time...end_time)
186
- success_data << hour_scope.successful.count
187
- failed_data << hour_scope.failed.count
220
+ s = row&.success_count.to_i
221
+ f = row&.failed_count.to_i
222
+ c = row&.total_cost.to_f
223
+
224
+ success_data << s
225
+ failed_data << f
226
+ cost_data << c.round(4)
227
+
228
+ total_success += s
229
+ total_failed += f
230
+ total_cost += c
188
231
  end
189
232
 
190
233
  {
191
- categories: categories,
234
+ range: "today",
235
+ totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
192
236
  series: [
193
237
  { name: "Success", data: success_data },
194
- { name: "Failed", data: failed_data }
238
+ { name: "Failed", data: failed_data },
239
+ { name: "Cost", data: cost_data }
195
240
  ]
196
241
  }
197
242
  end
198
243
 
244
+ # Builds daily chart data for specified number of days
245
+ # Optimized: Single GROUP BY query instead of 3*days individual queries
246
+ def build_daily_chart_data(days)
247
+ end_date = Date.current
248
+ start_date = (days - 1).days.ago.to_date
249
+
250
+ # Single query with GROUP BY - reduces 3*days queries to 1
251
+ results = where(created_at: start_date.beginning_of_day..end_date.end_of_day)
252
+ .group(Arel.sql("DATE_TRUNC('day', created_at)"))
253
+ .select(
254
+ Arel.sql("DATE_TRUNC('day', created_at) as time_bucket"),
255
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'success') as success_count"),
256
+ Arel.sql("COUNT(*) FILTER (WHERE status IN ('error', 'timeout')) as failed_count"),
257
+ Arel.sql("COALESCE(SUM(total_cost), 0) as total_cost")
258
+ )
259
+ .index_by { |r| r.time_bucket.to_date }
260
+
261
+ # Build arrays for all days (fill missing with zeros)
262
+ success_data = []
263
+ failed_data = []
264
+ cost_data = []
265
+ total_success = 0
266
+ total_failed = 0
267
+ total_cost = 0.0
268
+
269
+ (days - 1).downto(0).each do |days_ago|
270
+ date = days_ago.days.ago.to_date
271
+ row = results[date]
272
+
273
+ s = row&.success_count.to_i
274
+ f = row&.failed_count.to_i
275
+ c = row&.total_cost.to_f
276
+
277
+ success_data << s
278
+ failed_data << f
279
+ cost_data << c.round(4)
280
+
281
+ total_success += s
282
+ total_failed += f
283
+ total_cost += c
284
+ end
285
+
286
+ {
287
+ range: "#{days}d",
288
+ days: days,
289
+ totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
290
+ series: [
291
+ { name: "Success", data: success_data },
292
+ { name: "Failed", data: failed_data },
293
+ { name: "Cost", data: cost_data }
294
+ ]
295
+ }
296
+ end
297
+
298
+ public
299
+
199
300
  # Builds the hourly activity data structure
200
301
  # Shows the last 24 hours with current hour on the right
201
302
  #
@@ -85,7 +85,6 @@ module RubyLLM
85
85
 
86
86
  before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
87
87
  before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
88
- after_commit :broadcast_turbo_streams, on: %i[create update]
89
88
 
90
89
  # Aggregates costs from all attempts using each attempt's model pricing
91
90
  #
@@ -228,77 +227,39 @@ module RubyLLM
228
227
 
229
228
  # Returns real-time dashboard data for the Now Strip
230
229
  #
230
+ # @param range [String] Time range: "today", "7d", or "30d"
231
231
  # @return [Hash] Now strip metrics
232
- def self.now_strip_data
233
- today_scope = today
232
+ def self.now_strip_data(range: "today")
233
+ scope = case range
234
+ when "7d" then last_n_days(7)
235
+ when "30d" then last_n_days(30)
236
+ else today
237
+ end
238
+
234
239
  {
235
240
  running: running.count,
236
- success_today: today_scope.status_success.count,
237
- errors_today: today_scope.status_error.count,
238
- timeouts_today: today_scope.status_timeout.count,
239
- cost_today: today_scope.sum(:total_cost) || 0,
240
- executions_today: today_scope.count,
241
- success_rate: calculate_today_success_rate
241
+ success_today: scope.status_success.count,
242
+ errors_today: scope.status_error.count,
243
+ timeouts_today: scope.status_timeout.count,
244
+ cost_today: scope.sum(:total_cost) || 0,
245
+ executions_today: scope.count,
246
+ success_rate: calculate_period_success_rate(scope)
242
247
  }
243
248
  end
244
249
 
245
- # Calculates today's success rate
250
+ # Calculates success rate for a given scope
246
251
  #
252
+ # @param scope [ActiveRecord::Relation] The execution scope
247
253
  # @return [Float] Success rate as percentage
248
- def self.calculate_today_success_rate
249
- total = today.count
254
+ def self.calculate_period_success_rate(scope)
255
+ total = scope.count
250
256
  return 0.0 if total.zero?
251
257
 
252
- (today.successful.count.to_f / total * 100).round(1)
253
- end
254
-
255
- # Broadcasts execution changes via ActionCable for real-time dashboard updates
256
- #
257
- # Sends JSON with action, id, status, and rendered HTML partials.
258
- # The JavaScript client handles DOM updates based on the action type.
259
- #
260
- # @return [void]
261
- def broadcast_turbo_streams
262
- ActionCable.server.broadcast(
263
- "ruby_llm_agents:executions",
264
- {
265
- action: previously_new_record? ? "created" : "updated",
266
- id: id,
267
- status: status,
268
- html: render_execution_html,
269
- now_strip_html: render_now_strip_html
270
- }
271
- )
272
- rescue StandardError => e
273
- Rails.logger.error("[RubyLLM::Agents] Failed to broadcast execution: #{e.message}")
258
+ (scope.successful.count.to_f / total * 100).round(1)
274
259
  end
275
260
 
276
261
  private
277
262
 
278
- # Renders the execution item partial for broadcast
279
- #
280
- # @return [String, nil] HTML string or nil if rendering fails
281
- def render_execution_html
282
- ApplicationController.render(
283
- partial: "rubyllm/agents/dashboard/execution_item",
284
- locals: { execution: self }
285
- )
286
- rescue StandardError
287
- nil
288
- end
289
-
290
- # Renders the Now Strip values partial for broadcast
291
- #
292
- # @return [String, nil] HTML string or nil if rendering fails
293
- def render_now_strip_html
294
- ApplicationController.render(
295
- partial: "rubyllm/agents/dashboard/now_strip_values",
296
- locals: { now_strip: self.class.now_strip_data }
297
- )
298
- rescue StandardError
299
- nil
300
- end
301
-
302
263
  # Calculates and sets total_tokens from input and output
303
264
  #
304
265
  # @return [Integer] The calculated total