ruby_llm-agents 1.0.0 → 1.2.0

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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
  3. data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
  4. data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
  7. data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
  8. data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
  9. data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
  10. data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
  12. data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
  13. data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
  14. data/app/models/ruby_llm/agents/execution.rb +50 -14
  15. data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
  16. data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
  17. data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
  18. data/app/models/ruby_llm/agents/tenant.rb +146 -0
  19. data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
  20. data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
  21. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  22. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  23. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  24. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  25. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  26. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  27. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  28. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  30. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  31. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  32. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  33. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  34. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  35. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  36. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  37. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  38. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  39. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  40. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  41. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  42. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  43. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  44. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  45. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  46. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  47. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  48. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  49. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  50. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  51. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  52. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  53. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  54. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  55. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  56. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  57. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  58. data/config/routes.rb +1 -1
  59. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  60. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  61. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  62. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  65. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  66. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  67. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  68. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  69. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  70. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  71. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
  72. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  73. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  74. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
  75. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  76. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  77. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  78. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  79. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  80. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  81. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  82. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  83. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  84. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  85. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  86. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  87. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  88. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  89. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  90. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
  91. data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
  92. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  93. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  94. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  95. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  96. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  97. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  98. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  99. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  100. data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
  101. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  102. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  103. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  104. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  105. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  106. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  107. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  108. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  109. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  110. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  111. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  112. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  113. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  114. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  115. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  116. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  117. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  118. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
  119. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  120. data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
  121. data/lib/ruby_llm/agents/core/version.rb +1 -1
  122. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  123. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
  124. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  125. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  126. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  127. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  128. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  129. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  130. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  131. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  132. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  133. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  134. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  135. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  136. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  137. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  138. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  139. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  140. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  141. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  142. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  143. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  144. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  145. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  146. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  147. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  148. metadata +43 -6
  149. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  150. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  151. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  152. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
@@ -169,18 +169,33 @@ module RubyLLM
169
169
  # Returns chart data as arrays for Highcharts live updates
170
170
  # Format: { categories: [...], series: [...], range: ... }
171
171
  #
172
- # @param range [String] Time range: "today" (hourly), "7d" or "30d" (daily)
173
- def activity_chart_json(range: "today")
172
+ # @param range [String] Time range: "today" (hourly), "7d", "30d", "60d", or "90d" (daily)
173
+ # @param offset_days [Integer, nil] Optional offset for comparison data (shifts time window back)
174
+ def activity_chart_json(range: "today", offset_days: nil)
174
175
  case range
175
176
  when "7d"
176
- build_daily_chart_data(7)
177
+ build_daily_chart_data(7, offset_days: offset_days)
177
178
  when "30d"
178
- build_daily_chart_data(30)
179
+ build_daily_chart_data(30, offset_days: offset_days)
180
+ when "60d"
181
+ build_daily_chart_data(60, offset_days: offset_days)
182
+ when "90d"
183
+ build_daily_chart_data(90, offset_days: offset_days)
179
184
  else
180
- build_hourly_chart_data
185
+ build_hourly_chart_data(offset_days: offset_days)
181
186
  end
182
187
  end
183
188
 
189
+ # Returns chart data for a custom date range
190
+ # Format: { categories: [...], series: [...], range: ... }
191
+ #
192
+ # @param from [Date] Start date (inclusive)
193
+ # @param to [Date] End date (inclusive)
194
+ # @return [Hash] Chart data with series arrays
195
+ def activity_chart_json_for_dates(from:, to:)
196
+ build_daily_chart_data_for_dates(from, to)
197
+ end
198
+
184
199
  # Alias for backwards compatibility
185
200
  def hourly_activity_chart_json
186
201
  activity_chart_json(range: "today")
@@ -191,22 +206,30 @@ module RubyLLM
191
206
  # Builds hourly chart data for last 24 hours
192
207
  # Optimized: Single GROUP BY query instead of 72 individual queries
193
208
  # Database-agnostic: works with both PostgreSQL and SQLite
194
- def build_hourly_chart_data
195
- reference_time = Time.current.beginning_of_hour
209
+ #
210
+ # @param offset_days [Integer, nil] Optional offset for comparison data
211
+ def build_hourly_chart_data(offset_days: nil)
212
+ offset = offset_days ? offset_days.days : 0.days
213
+ reference_time = (Time.current - offset).beginning_of_hour
196
214
  start_time = reference_time - 23.hours
197
215
 
198
216
  # Use database-agnostic aggregation with Ruby post-processing
199
217
  results = where(created_at: start_time..(reference_time + 1.hour))
200
- .select(:status, :total_cost, :created_at)
218
+ .select(:status, :total_cost, :duration_ms, :input_tokens, :output_tokens, :created_at)
201
219
  .group_by { |r| r.created_at.beginning_of_hour }
202
220
 
203
221
  # Build arrays for all 24 hours (fill missing with zeros)
204
222
  success_data = []
205
223
  failed_data = []
206
224
  cost_data = []
225
+ duration_data = []
226
+ tokens_data = []
207
227
  total_success = 0
208
228
  total_failed = 0
209
229
  total_cost = 0.0
230
+ total_duration_sum = 0
231
+ total_duration_count = 0
232
+ total_tokens = 0
210
233
 
211
234
  (23.downto(0)).each do |hours_ago|
212
235
  bucket_time = (reference_time - hours_ago.hours).beginning_of_hour
@@ -215,23 +238,43 @@ module RubyLLM
215
238
  s = rows.count { |r| r.status == "success" }
216
239
  f = rows.count { |r| r.status.in?(%w[error timeout]) }
217
240
  c = rows.sum { |r| r.total_cost.to_f }
241
+ t = rows.sum { |r| (r.input_tokens || 0) + (r.output_tokens || 0) }
242
+
243
+ # Average duration for this bucket
244
+ duration_rows = rows.select { |r| r.duration_ms.to_i > 0 }
245
+ d = duration_rows.any? ? (duration_rows.sum { |r| r.duration_ms.to_i } / duration_rows.count) : 0
218
246
 
219
247
  success_data << s
220
248
  failed_data << f
221
249
  cost_data << c.round(4)
250
+ duration_data << d.round
251
+ tokens_data << t
222
252
 
223
253
  total_success += s
224
254
  total_failed += f
225
255
  total_cost += c
256
+ total_tokens += t
257
+ total_duration_sum += duration_rows.sum { |r| r.duration_ms.to_i }
258
+ total_duration_count += duration_rows.count
226
259
  end
227
260
 
261
+ avg_duration_ms = total_duration_count > 0 ? (total_duration_sum / total_duration_count).round : 0
262
+
228
263
  {
229
264
  range: "today",
230
- totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
265
+ totals: {
266
+ success: total_success,
267
+ failed: total_failed,
268
+ cost: total_cost.round(4),
269
+ duration_ms: avg_duration_ms,
270
+ tokens: total_tokens
271
+ },
231
272
  series: [
232
273
  { name: "Success", data: success_data },
233
- { name: "Failed", data: failed_data },
234
- { name: "Cost", data: cost_data }
274
+ { name: "Errors", data: failed_data },
275
+ { name: "Cost", data: cost_data },
276
+ { name: "Duration", data: duration_data },
277
+ { name: "Tokens", data: tokens_data }
235
278
  ]
236
279
  }
237
280
  end
@@ -239,48 +282,155 @@ module RubyLLM
239
282
  # Builds daily chart data for specified number of days
240
283
  # Optimized: Single query instead of 3*days individual queries
241
284
  # Database-agnostic: works with both PostgreSQL and SQLite
242
- def build_daily_chart_data(days)
243
- end_date = Date.current
244
- start_date = (days - 1).days.ago.to_date
285
+ #
286
+ # @param days [Integer] Number of days to include
287
+ # @param offset_days [Integer, nil] Optional offset for comparison data
288
+ def build_daily_chart_data(days, offset_days: nil)
289
+ offset = offset_days || 0
290
+ end_date = Date.current - offset.days
291
+ start_date = end_date - (days - 1).days
245
292
 
246
293
  # Use database-agnostic aggregation with Ruby post-processing
247
294
  results = where(created_at: start_date.beginning_of_day..end_date.end_of_day)
248
- .select(:status, :total_cost, :created_at)
295
+ .select(:status, :total_cost, :duration_ms, :input_tokens, :output_tokens, :created_at)
249
296
  .group_by { |r| r.created_at.to_date }
250
297
 
251
298
  # Build arrays for all days (fill missing with zeros)
252
299
  success_data = []
253
300
  failed_data = []
254
301
  cost_data = []
302
+ duration_data = []
303
+ tokens_data = []
255
304
  total_success = 0
256
305
  total_failed = 0
257
306
  total_cost = 0.0
307
+ total_duration_sum = 0
308
+ total_duration_count = 0
309
+ total_tokens = 0
258
310
 
259
- (days - 1).downto(0).each do |days_ago|
260
- date = days_ago.days.ago.to_date
311
+ (days - 1).downto(0).each do |i|
312
+ date = end_date - i.days
261
313
  rows = results[date] || []
262
314
 
263
315
  s = rows.count { |r| r.status == "success" }
264
316
  f = rows.count { |r| r.status.in?(%w[error timeout]) }
265
317
  c = rows.sum { |r| r.total_cost.to_f }
318
+ t = rows.sum { |r| (r.input_tokens || 0) + (r.output_tokens || 0) }
319
+
320
+ # Average duration for this bucket
321
+ duration_rows = rows.select { |r| r.duration_ms.to_i > 0 }
322
+ d = duration_rows.any? ? (duration_rows.sum { |r| r.duration_ms.to_i } / duration_rows.count) : 0
266
323
 
267
324
  success_data << s
268
325
  failed_data << f
269
326
  cost_data << c.round(4)
327
+ duration_data << d.round
328
+ tokens_data << t
270
329
 
271
330
  total_success += s
272
331
  total_failed += f
273
332
  total_cost += c
333
+ total_tokens += t
334
+ total_duration_sum += duration_rows.sum { |r| r.duration_ms.to_i }
335
+ total_duration_count += duration_rows.count
274
336
  end
275
337
 
338
+ avg_duration_ms = total_duration_count > 0 ? (total_duration_sum / total_duration_count).round : 0
339
+
276
340
  {
277
341
  range: "#{days}d",
278
342
  days: days,
279
- totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
343
+ totals: {
344
+ success: total_success,
345
+ failed: total_failed,
346
+ cost: total_cost.round(4),
347
+ duration_ms: avg_duration_ms,
348
+ tokens: total_tokens
349
+ },
350
+ series: [
351
+ { name: "Success", data: success_data },
352
+ { name: "Errors", data: failed_data },
353
+ { name: "Cost", data: cost_data },
354
+ { name: "Duration", data: duration_data },
355
+ { name: "Tokens", data: tokens_data }
356
+ ]
357
+ }
358
+ end
359
+
360
+ # Builds daily chart data for a custom date range
361
+ # Database-agnostic: works with both PostgreSQL and SQLite
362
+ #
363
+ # @param from_date [Date] Start date (inclusive)
364
+ # @param to_date [Date] End date (inclusive)
365
+ # @return [Hash] Chart data with series arrays
366
+ def build_daily_chart_data_for_dates(from_date, to_date)
367
+ days = (to_date - from_date).to_i + 1
368
+
369
+ # Use database-agnostic aggregation with Ruby post-processing
370
+ results = where(created_at: from_date.beginning_of_day..to_date.end_of_day)
371
+ .select(:status, :total_cost, :duration_ms, :input_tokens, :output_tokens, :created_at)
372
+ .group_by { |r| r.created_at.to_date }
373
+
374
+ # Build arrays for all days (fill missing with zeros)
375
+ success_data = []
376
+ failed_data = []
377
+ cost_data = []
378
+ duration_data = []
379
+ tokens_data = []
380
+ total_success = 0
381
+ total_failed = 0
382
+ total_cost = 0.0
383
+ total_duration_sum = 0
384
+ total_duration_count = 0
385
+ total_tokens = 0
386
+
387
+ (0...days).each do |i|
388
+ date = from_date + i.days
389
+ rows = results[date] || []
390
+
391
+ s = rows.count { |r| r.status == "success" }
392
+ f = rows.count { |r| r.status.in?(%w[error timeout]) }
393
+ c = rows.sum { |r| r.total_cost.to_f }
394
+ t = rows.sum { |r| (r.input_tokens || 0) + (r.output_tokens || 0) }
395
+
396
+ # Average duration for this bucket
397
+ duration_rows = rows.select { |r| r.duration_ms.to_i > 0 }
398
+ d = duration_rows.any? ? (duration_rows.sum { |r| r.duration_ms.to_i } / duration_rows.count) : 0
399
+
400
+ success_data << s
401
+ failed_data << f
402
+ cost_data << c.round(4)
403
+ duration_data << d.round
404
+ tokens_data << t
405
+
406
+ total_success += s
407
+ total_failed += f
408
+ total_cost += c
409
+ total_tokens += t
410
+ total_duration_sum += duration_rows.sum { |r| r.duration_ms.to_i }
411
+ total_duration_count += duration_rows.count
412
+ end
413
+
414
+ avg_duration_ms = total_duration_count > 0 ? (total_duration_sum / total_duration_count).round : 0
415
+
416
+ {
417
+ range: "custom",
418
+ days: days,
419
+ from: from_date.to_s,
420
+ to: to_date.to_s,
421
+ totals: {
422
+ success: total_success,
423
+ failed: total_failed,
424
+ cost: total_cost.round(4),
425
+ duration_ms: avg_duration_ms,
426
+ tokens: total_tokens
427
+ },
280
428
  series: [
281
429
  { name: "Success", data: success_data },
282
- { name: "Failed", data: failed_data },
283
- { name: "Cost", data: cost_data }
430
+ { name: "Errors", data: failed_data },
431
+ { name: "Cost", data: cost_data },
432
+ { name: "Duration", data: duration_data },
433
+ { name: "Tokens", data: tokens_data }
284
434
  ]
285
435
  }
286
436
  end
@@ -156,37 +156,6 @@ module RubyLLM
156
156
 
157
157
  # @!endgroup
158
158
 
159
- # @!group Search Scopes
160
-
161
- # @!method search(query)
162
- # Free-text search across error fields and parameters
163
- # @param query [String] Search query
164
- # @return [ActiveRecord::Relation]
165
- scope :search, ->(query) do
166
- return all if query.blank?
167
-
168
- sanitized_query = "%#{sanitize_sql_like(query)}%"
169
- # Use database-agnostic case-insensitive search
170
- # PostgreSQL: ILIKE, SQLite: LIKE with LOWER() + ESCAPE clause
171
- if connection.adapter_name.downcase.include?("postgresql")
172
- where(
173
- "error_class ILIKE :q OR error_message ILIKE :q OR CAST(parameters AS TEXT) ILIKE :q",
174
- q: sanitized_query
175
- )
176
- else
177
- # SQLite and other databases need ESCAPE clause for backslash to work
178
- sanitized_query_lower = sanitized_query.downcase
179
- where(
180
- "LOWER(error_class) LIKE :q ESCAPE '\\' OR " \
181
- "LOWER(error_message) LIKE :q ESCAPE '\\' OR " \
182
- "LOWER(CAST(parameters AS TEXT)) LIKE :q ESCAPE '\\'",
183
- q: sanitized_query_lower
184
- )
185
- end
186
- end
187
-
188
- # @!endgroup
189
-
190
159
  # @!group Tracing Scopes
191
160
 
192
161
  # @!method by_trace(trace_id)
@@ -21,27 +21,6 @@ module RubyLLM
21
21
  workflow_type.present?
22
22
  end
23
23
 
24
- # Returns whether this is a pipeline workflow
25
- #
26
- # @return [Boolean] true if workflow_type is "pipeline"
27
- def pipeline_workflow?
28
- workflow_type == "pipeline"
29
- end
30
-
31
- # Returns whether this is a parallel workflow
32
- #
33
- # @return [Boolean] true if workflow_type is "parallel"
34
- def parallel_workflow?
35
- workflow_type == "parallel"
36
- end
37
-
38
- # Returns whether this is a router workflow
39
- #
40
- # @return [Boolean] true if workflow_type is "router"
41
- def router_workflow?
42
- workflow_type == "router"
43
- end
44
-
45
24
  # Returns whether this is a root workflow execution (top-level)
46
25
  #
47
26
  # @return [Boolean] true if this is a workflow with no parent
@@ -134,114 +113,6 @@ module RubyLLM
134
113
 
135
114
  # @!endgroup
136
115
 
137
- # @!group Pipeline-specific Methods
138
-
139
- # Returns pipeline steps in order with their status
140
- #
141
- # @return [Array<Hash>] Array of step hashes with name, status, duration, cost
142
- def pipeline_steps_detail
143
- return [] unless pipeline_workflow?
144
-
145
- workflow_steps.map do |step|
146
- {
147
- id: step.id,
148
- name: step.workflow_step || step.agent_type.gsub(/Agent$/, ""),
149
- agent_type: step.agent_type,
150
- status: step.status,
151
- duration_ms: step.duration_ms,
152
- total_cost: step.total_cost,
153
- total_tokens: step.total_tokens,
154
- model_id: step.model_id
155
- }
156
- end
157
- end
158
-
159
- # @!endgroup
160
-
161
- # @!group Parallel-specific Methods
162
-
163
- # Returns parallel branches with their status and timing
164
- #
165
- # @return [Array<Hash>] Array of branch hashes
166
- def parallel_branches_detail
167
- return [] unless parallel_workflow?
168
-
169
- branches = workflow_steps.to_a
170
- return [] if branches.empty?
171
-
172
- # Find min/max for timing comparison
173
- min_duration = branches.map { |b| b.duration_ms || 0 }.min
174
- max_duration = branches.map { |b| b.duration_ms || 0 }.max
175
-
176
- branches.map do |branch|
177
- duration = branch.duration_ms || 0
178
- {
179
- id: branch.id,
180
- name: branch.workflow_step || branch.agent_type.gsub(/Agent$/, ""),
181
- agent_type: branch.agent_type,
182
- status: branch.status,
183
- duration_ms: duration,
184
- total_cost: branch.total_cost,
185
- total_tokens: branch.total_tokens,
186
- model_id: branch.model_id,
187
- is_fastest: duration == min_duration && branches.size > 1,
188
- is_slowest: duration == max_duration && branches.size > 1 && min_duration != max_duration
189
- }
190
- end
191
- end
192
-
193
- # @!endgroup
194
-
195
- # @!group Router-specific Methods
196
-
197
- # Returns router classification details
198
- #
199
- # @return [Hash] Classification info including method, model, timing
200
- def router_classification_detail
201
- return {} unless router_workflow?
202
-
203
- result = if classification_result.is_a?(String)
204
- begin
205
- JSON.parse(classification_result)
206
- rescue JSON::ParserError
207
- {}
208
- end
209
- else
210
- classification_result || {}
211
- end
212
-
213
- {
214
- method: result["method"],
215
- classifier_model: result["classifier_model"],
216
- classification_time_ms: result["classification_time_ms"],
217
- routed_to: routed_to,
218
- confidence: result["confidence"]
219
- }
220
- end
221
-
222
- # Returns available routes and which one was chosen
223
- #
224
- # @return [Hash] Routes info with chosen route highlighted
225
- def router_routes_detail
226
- return {} unless router_workflow?
227
-
228
- # Get the routed execution (child)
229
- routed_child = child_executions.first
230
-
231
- {
232
- chosen_route: routed_to,
233
- routed_execution: routed_child ? {
234
- id: routed_child.id,
235
- agent_type: routed_child.agent_type,
236
- status: routed_child.status,
237
- duration_ms: routed_child.duration_ms,
238
- total_cost: routed_child.total_cost
239
- } : nil
240
- }
241
- end
242
-
243
- # @!endgroup
244
-
245
116
  private
246
117
 
247
118
  # Returns empty aggregate stats hash
@@ -239,23 +239,59 @@ module RubyLLM
239
239
  # Returns real-time dashboard data for the Now Strip
240
240
  #
241
241
  # @param range [String] Time range: "today", "7d", or "30d"
242
- # @return [Hash] Now strip metrics
242
+ # @return [Hash] Now strip metrics with period-over-period comparisons
243
243
  def self.now_strip_data(range: "today")
244
- scope = case range
245
- when "7d" then last_n_days(7)
246
- when "30d" then last_n_days(30)
247
- else today
248
- end
249
-
250
- {
244
+ current_scope = case range
245
+ when "7d" then last_n_days(7)
246
+ when "30d" then last_n_days(30)
247
+ else today
248
+ end
249
+
250
+ previous_scope = case range
251
+ when "7d" then where(created_at: 14.days.ago.beginning_of_day..7.days.ago.beginning_of_day)
252
+ when "30d" then where(created_at: 60.days.ago.beginning_of_day..30.days.ago.beginning_of_day)
253
+ else yesterday
254
+ end
255
+
256
+ current = {
251
257
  running: running.count,
252
- success_today: scope.status_success.count,
253
- errors_today: scope.status_error.count,
254
- timeouts_today: scope.status_timeout.count,
255
- cost_today: scope.sum(:total_cost) || 0,
256
- executions_today: scope.count,
257
- success_rate: calculate_period_success_rate(scope)
258
+ success_today: current_scope.status_success.count,
259
+ errors_today: current_scope.status_error.count,
260
+ timeouts_today: current_scope.status_timeout.count,
261
+ cost_today: current_scope.sum(:total_cost) || 0,
262
+ executions_today: current_scope.count,
263
+ success_rate: calculate_period_success_rate(current_scope),
264
+ avg_duration_ms: current_scope.avg_duration&.round || 0,
265
+ total_tokens: current_scope.total_tokens_sum || 0
266
+ }
267
+
268
+ previous = {
269
+ success: previous_scope.status_success.count,
270
+ errors: previous_scope.status_error.count,
271
+ cost: previous_scope.sum(:total_cost) || 0,
272
+ avg_duration_ms: previous_scope.avg_duration&.round || 0,
273
+ total_tokens: previous_scope.total_tokens_sum || 0
258
274
  }
275
+
276
+ current.merge(
277
+ comparisons: {
278
+ success_change: pct_change(previous[:success], current[:success_today]),
279
+ errors_change: pct_change(previous[:errors], current[:errors_today]),
280
+ cost_change: pct_change(previous[:cost], current[:cost_today]),
281
+ duration_change: pct_change(previous[:avg_duration_ms], current[:avg_duration_ms]),
282
+ tokens_change: pct_change(previous[:total_tokens], current[:total_tokens])
283
+ }
284
+ )
285
+ end
286
+
287
+ # Calculates percentage change between old and new values
288
+ #
289
+ # @param old_val [Numeric, nil] Previous period value
290
+ # @param new_val [Numeric] Current period value
291
+ # @return [Float, nil] Percentage change or nil if old_val is nil/zero
292
+ def self.pct_change(old_val, new_val)
293
+ return nil if old_val.nil? || old_val.zero?
294
+ ((new_val - old_val).to_f / old_val * 100).round(1)
259
295
  end
260
296
 
261
297
  # Calculates success rate for a given scope