ruby_llm-agents 0.5.0 → 1.0.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +189 -31
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  5. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  6. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  7. data/app/models/ruby_llm/agents/execution.rb +3 -0
  8. data/app/models/ruby_llm/agents/tenant_budget.rb +58 -15
  9. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +2 -29
  11. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  12. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  13. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  14. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  15. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  16. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  17. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  18. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  19. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  20. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  21. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  22. data/app/views/ruby_llm/agents/executions/show.html.erb +16 -0
  23. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  24. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  25. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  26. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  27. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  28. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  29. data/config/routes.rb +1 -0
  30. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  31. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  32. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  33. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  34. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  35. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  36. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  37. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  38. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  39. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  40. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  41. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  42. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  43. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  44. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  45. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  46. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  47. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  48. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  49. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  50. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  51. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  52. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  53. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  54. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  55. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  56. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  57. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  58. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  59. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  60. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  61. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  62. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  63. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  64. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  65. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  66. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  67. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  68. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  69. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  70. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  71. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  72. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  73. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  74. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  75. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  76. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  77. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  78. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  79. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  80. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  81. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  82. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  83. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  84. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  85. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  86. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  87. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  88. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  89. data/lib/ruby_llm/agents/core/base.rb +135 -0
  90. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  91. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  92. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +22 -1
  93. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  94. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  95. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  96. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  97. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  98. data/lib/ruby_llm/agents/dsl.rb +41 -0
  99. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  100. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  101. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  102. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  103. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  104. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  105. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  106. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  107. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  108. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  109. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  110. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  111. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  112. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  113. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  114. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  115. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  116. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  117. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  118. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  119. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  120. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  121. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  122. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  123. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  124. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  125. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  126. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  127. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  128. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  129. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  130. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  131. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  132. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  133. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  134. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  135. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  136. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  137. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  138. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  139. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  140. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  141. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  142. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  143. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  144. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  145. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  146. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  147. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -11
  148. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  149. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  150. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  151. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  152. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  153. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  154. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  155. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  156. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  157. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  158. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  159. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  160. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  161. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  162. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  163. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  164. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  165. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  166. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  167. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  168. data/lib/ruby_llm/agents.rb +86 -20
  169. metadata +172 -34
  170. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  171. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  172. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  173. data/lib/ruby_llm/agents/base/execution.rb +0 -366
  174. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  175. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  176. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  177. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  178. data/lib/ruby_llm/agents/base.rb +0 -210
  179. data/lib/ruby_llm/agents/budget_tracker.rb +0 -733
  180. data/lib/ruby_llm/agents/configuration.rb +0 -394
  181. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  182. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  183. /data/lib/ruby_llm/agents/{resolved_config.rb → core/resolved_config.rb} +0 -0
  184. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  185. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  186. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  187. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  188. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  189. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  190. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -93,6 +93,24 @@ module RubyLLM
93
93
  # @return [Integer] Number of tool calls made
94
94
  attr_reader :tool_calls, :tool_calls_count
95
95
 
96
+ # @!group Thinking
97
+ # @!attribute [r] thinking_text
98
+ # @return [String, nil] The reasoning/thinking content from the model
99
+ # @!attribute [r] thinking_signature
100
+ # @return [String, nil] Signature for multi-turn thinking continuity (Claude)
101
+ # @!attribute [r] thinking_tokens
102
+ # @return [Integer, nil] Number of tokens used for thinking
103
+ attr_reader :thinking_text, :thinking_signature, :thinking_tokens
104
+
105
+ # @!group Moderation
106
+ # @!attribute [r] status
107
+ # @return [Symbol, nil] Result status (:success, :input_moderation_blocked, :output_moderation_blocked)
108
+ # @!attribute [r] moderation_result
109
+ # @return [Object, nil] The raw moderation result from RubyLLM
110
+ # @!attribute [r] moderation_phase
111
+ # @return [Symbol, nil] The phase where moderation blocked (:input or :output)
112
+ attr_reader :status, :moderation_result, :moderation_phase
113
+
96
114
  # Creates a new Result instance
97
115
  #
98
116
  # @param content [Hash, String] The processed response content
@@ -137,6 +155,17 @@ module RubyLLM
137
155
  # Tool calls
138
156
  @tool_calls = options[:tool_calls] || []
139
157
  @tool_calls_count = options[:tool_calls_count] || 0
158
+
159
+ # Thinking
160
+ @thinking_text = options[:thinking_text]
161
+ @thinking_signature = options[:thinking_signature]
162
+ @thinking_tokens = options[:thinking_tokens]
163
+
164
+ # Moderation
165
+ @status = options[:status] || :success
166
+ @moderation_flagged = options[:moderation_flagged] || false
167
+ @moderation_result = options[:moderation_result]
168
+ @moderation_phase = options[:moderation_phase]
140
169
  end
141
170
 
142
171
  # Returns total tokens (input + output)
@@ -188,6 +217,41 @@ module RubyLLM
188
217
  tool_calls_count.to_i > 0
189
218
  end
190
219
 
220
+ # Returns whether thinking data is present in the result
221
+ #
222
+ # @return [Boolean] true if thinking_text is present
223
+ def has_thinking?
224
+ thinking_text.present?
225
+ end
226
+
227
+ # Returns whether content was flagged by moderation
228
+ #
229
+ # @return [Boolean] true if moderation flagged the content
230
+ def moderation_flagged?
231
+ @moderation_flagged == true
232
+ end
233
+
234
+ # Returns whether content passed moderation
235
+ #
236
+ # @return [Boolean] true if content was not flagged
237
+ def moderation_passed?
238
+ !moderation_flagged?
239
+ end
240
+
241
+ # Returns the categories flagged by moderation
242
+ #
243
+ # @return [Array<String, Symbol>] Flagged category names
244
+ def moderation_categories
245
+ @moderation_result&.flagged_categories || []
246
+ end
247
+
248
+ # Returns the moderation category scores
249
+ #
250
+ # @return [Hash{String, Symbol => Float}] Category to score mapping
251
+ def moderation_scores
252
+ @moderation_result&.category_scores || {}
253
+ end
254
+
191
255
  # Converts the result to a hash
192
256
  #
193
257
  # @return [Hash] All result data as a hash
@@ -216,7 +280,15 @@ module RubyLLM
216
280
  attempts_count: attempts_count,
217
281
  attempts: attempts,
218
282
  tool_calls: tool_calls,
219
- tool_calls_count: tool_calls_count
283
+ tool_calls_count: tool_calls_count,
284
+ thinking_text: thinking_text,
285
+ thinking_signature: thinking_signature,
286
+ thinking_tokens: thinking_tokens,
287
+ status: status,
288
+ moderation_flagged: moderation_flagged?,
289
+ moderation_phase: moderation_phase,
290
+ moderation_categories: moderation_categories,
291
+ moderation_scores: moderation_scores
220
292
  }
221
293
  end
222
294
 
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Result object for embedding operations
6
+ #
7
+ # Wraps embedding vectors with metadata about the operation including
8
+ # token usage, cost, timing, and utility methods for similarity calculations.
9
+ #
10
+ # @example Single text embedding
11
+ # result = MyEmbedder.call(text: "Hello world")
12
+ # result.vector # => [0.123, -0.456, ...]
13
+ # result.dimensions # => 1536
14
+ # result.input_tokens # => 2
15
+ #
16
+ # @example Batch embedding
17
+ # result = MyEmbedder.call(texts: ["Hello", "World"])
18
+ # result.vectors # => [[...], [...]]
19
+ # result.count # => 2
20
+ #
21
+ # @example Similarity comparison
22
+ # result1 = MyEmbedder.call(text: "Ruby programming")
23
+ # result2 = MyEmbedder.call(text: "Python programming")
24
+ # result1.similarity(result2) # => 0.85
25
+ #
26
+ # @api public
27
+ class EmbeddingResult
28
+ # @!attribute [r] vectors
29
+ # @return [Array<Array<Float>>] The embedding vectors
30
+ attr_reader :vectors
31
+
32
+ # @!attribute [r] model_id
33
+ # @return [String, nil] The embedding model used
34
+ attr_reader :model_id
35
+
36
+ # @!attribute [r] dimensions
37
+ # @return [Integer, nil] The dimensionality of the vectors
38
+ attr_reader :dimensions
39
+
40
+ # @!attribute [r] input_tokens
41
+ # @return [Integer, nil] Number of input tokens consumed
42
+ attr_reader :input_tokens
43
+
44
+ # @!attribute [r] total_cost
45
+ # @return [Float, nil] Total cost in USD
46
+ attr_reader :total_cost
47
+
48
+ # @!attribute [r] duration_ms
49
+ # @return [Integer, nil] Execution duration in milliseconds
50
+ attr_reader :duration_ms
51
+
52
+ # @!attribute [r] count
53
+ # @return [Integer] Number of texts embedded
54
+ attr_reader :count
55
+
56
+ # @!attribute [r] started_at
57
+ # @return [Time, nil] When execution started
58
+ attr_reader :started_at
59
+
60
+ # @!attribute [r] completed_at
61
+ # @return [Time, nil] When execution completed
62
+ attr_reader :completed_at
63
+
64
+ # @!attribute [r] tenant_id
65
+ # @return [String, nil] Tenant identifier if multi-tenancy enabled
66
+ attr_reader :tenant_id
67
+
68
+ # @!attribute [r] error_class
69
+ # @return [String, nil] Exception class name if failed
70
+ attr_reader :error_class
71
+
72
+ # @!attribute [r] error_message
73
+ # @return [String, nil] Exception message if failed
74
+ attr_reader :error_message
75
+
76
+ # Creates a new EmbeddingResult instance
77
+ #
78
+ # @param attributes [Hash] Result attributes
79
+ # @option attributes [Array<Array<Float>>] :vectors The embedding vectors
80
+ # @option attributes [String] :model_id The model used
81
+ # @option attributes [Integer] :dimensions Vector dimensionality
82
+ # @option attributes [Integer] :input_tokens Tokens consumed
83
+ # @option attributes [Float] :total_cost Cost in USD
84
+ # @option attributes [Integer] :duration_ms Duration in milliseconds
85
+ # @option attributes [Integer] :count Number of texts
86
+ # @option attributes [Time] :started_at Start time
87
+ # @option attributes [Time] :completed_at Completion time
88
+ # @option attributes [String] :tenant_id Tenant identifier
89
+ # @option attributes [String] :error_class Error class name
90
+ # @option attributes [String] :error_message Error message
91
+ def initialize(attributes = {})
92
+ @vectors = attributes[:vectors] || []
93
+ @model_id = attributes[:model_id]
94
+ @dimensions = attributes[:dimensions]
95
+ @input_tokens = attributes[:input_tokens]
96
+ @total_cost = attributes[:total_cost]
97
+ @duration_ms = attributes[:duration_ms]
98
+ @count = attributes[:count] || @vectors.size
99
+ @started_at = attributes[:started_at]
100
+ @completed_at = attributes[:completed_at]
101
+ @tenant_id = attributes[:tenant_id]
102
+ @error_class = attributes[:error_class]
103
+ @error_message = attributes[:error_message]
104
+ end
105
+
106
+ # Returns whether this result contains a single embedding
107
+ #
108
+ # @return [Boolean] true if count is 1
109
+ def single?
110
+ count == 1
111
+ end
112
+
113
+ # Returns whether this result contains multiple embeddings
114
+ #
115
+ # @return [Boolean] true if count > 1
116
+ def batch?
117
+ count > 1
118
+ end
119
+
120
+ # Returns the first (or only) embedding vector
121
+ #
122
+ # Convenience method for single-text embeddings.
123
+ #
124
+ # @return [Array<Float>, nil] The embedding vector or nil if batch
125
+ def vector
126
+ vectors.first
127
+ end
128
+
129
+ # Returns whether the execution succeeded
130
+ #
131
+ # @return [Boolean] true if no error occurred
132
+ def success?
133
+ error_class.nil?
134
+ end
135
+
136
+ # Returns whether the execution failed
137
+ #
138
+ # @return [Boolean] true if an error occurred
139
+ def error?
140
+ !success?
141
+ end
142
+
143
+ # Calculates cosine similarity between this embedding and another
144
+ #
145
+ # @param other [EmbeddingResult, Array<Float>] Another embedding or vector
146
+ # @param index [Integer] Index of the vector to compare (for batch results)
147
+ # @return [Float] Cosine similarity score (-1.0 to 1.0)
148
+ # @example Compare two results
149
+ # result1.similarity(result2) # => 0.85
150
+ # @example Compare with raw vector
151
+ # result.similarity([0.1, 0.2, 0.3])
152
+ # @example Compare specific vector from batch
153
+ # batch_result.similarity(other, index: 2)
154
+ def similarity(other, index: 0)
155
+ v1 = vectors[index]
156
+ return nil if v1.nil?
157
+
158
+ v2 = case other
159
+ when EmbeddingResult
160
+ other.vector
161
+ when Array
162
+ other
163
+ else
164
+ raise ArgumentError, "other must be EmbeddingResult or Array, got #{other.class}"
165
+ end
166
+
167
+ return nil if v2.nil?
168
+
169
+ cosine_similarity(v1, v2)
170
+ end
171
+
172
+ # Finds the most similar vectors from a collection
173
+ #
174
+ # @param others [Array<EmbeddingResult, Array<Float>>] Collection to search
175
+ # @param limit [Integer] Maximum results to return
176
+ # @param index [Integer] Index of the source vector (for batch results)
177
+ # @return [Array<Hash>] Sorted results with :index and :similarity keys
178
+ # @example Find top 5 similar
179
+ # result.most_similar(document_embeddings, limit: 5)
180
+ def most_similar(others, limit: 10, index: 0)
181
+ v1 = vectors[index]
182
+ return [] if v1.nil?
183
+
184
+ similarities = others.each_with_index.map do |other, idx|
185
+ v2 = case other
186
+ when EmbeddingResult
187
+ other.vector
188
+ when Array
189
+ other
190
+ else
191
+ next nil
192
+ end
193
+
194
+ next nil if v2.nil?
195
+
196
+ { index: idx, similarity: cosine_similarity(v1, v2) }
197
+ end.compact
198
+
199
+ similarities.sort_by { |s| -s[:similarity] }.first(limit)
200
+ end
201
+
202
+ # Converts the result to a hash
203
+ #
204
+ # @return [Hash] All result data as a hash
205
+ def to_h
206
+ {
207
+ vectors: vectors,
208
+ model_id: model_id,
209
+ dimensions: dimensions,
210
+ input_tokens: input_tokens,
211
+ total_cost: total_cost,
212
+ duration_ms: duration_ms,
213
+ count: count,
214
+ started_at: started_at,
215
+ completed_at: completed_at,
216
+ tenant_id: tenant_id,
217
+ error_class: error_class,
218
+ error_message: error_message
219
+ }
220
+ end
221
+
222
+ private
223
+
224
+ # Calculates cosine similarity between two vectors
225
+ #
226
+ # @param a [Array<Float>] First vector
227
+ # @param b [Array<Float>] Second vector
228
+ # @return [Float] Cosine similarity (-1.0 to 1.0)
229
+ def cosine_similarity(a, b)
230
+ return 0.0 if a.empty? || b.empty?
231
+ return 0.0 if a.size != b.size
232
+
233
+ dot_product = a.zip(b).sum { |x, y| x * y }
234
+ magnitude_a = Math.sqrt(a.sum { |x| x * x })
235
+ magnitude_b = Math.sqrt(b.sum { |x| x * x })
236
+
237
+ return 0.0 if magnitude_a.zero? || magnitude_b.zero?
238
+
239
+ dot_product / (magnitude_a * magnitude_b)
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Result wrapper for image analysis operations
6
+ #
7
+ # Provides a consistent interface for accessing analysis data
8
+ # including captions, tags, objects, colors, and extracted text.
9
+ #
10
+ # @example Accessing analysis data
11
+ # result = ImageAnalyzer.call(image: "photo.jpg")
12
+ # result.caption # => "A sunset over mountains"
13
+ # result.tags # => ["sunset", "mountains", "nature"]
14
+ # result.objects # => [{name: "mountain", location: "center", confidence: "high"}]
15
+ # result.colors # => [{hex: "#FF6B35", name: "orange", percentage: 30}]
16
+ # result.description # => "A detailed description..."
17
+ # result.success? # => true
18
+ #
19
+ class ImageAnalysisResult
20
+ attr_reader :image, :model_id, :analysis_type,
21
+ :caption, :description, :tags, :objects, :colors, :text,
22
+ :raw_response, :started_at, :completed_at, :tenant_id, :analyzer_class,
23
+ :error_class, :error_message
24
+
25
+ # Initialize a new result
26
+ #
27
+ # @param image [String] The analyzed image path or URL
28
+ # @param model_id [String] Model used for analysis
29
+ # @param analysis_type [Symbol] Type of analysis performed
30
+ # @param caption [String, nil] Brief caption of the image
31
+ # @param description [String, nil] Detailed description
32
+ # @param tags [Array<String>] Tags/keywords for the image
33
+ # @param objects [Array<Hash>] Detected objects with metadata
34
+ # @param colors [Array<Hash>] Dominant colors with hex, name, percentage
35
+ # @param text [String, nil] Extracted text (OCR)
36
+ # @param raw_response [Hash, String, nil] Raw response from the model
37
+ # @param started_at [Time] When analysis started
38
+ # @param completed_at [Time] When analysis completed
39
+ # @param tenant_id [String, nil] Tenant identifier
40
+ # @param analyzer_class [String] Name of the analyzer class
41
+ # @param error_class [String, nil] Error class name if failed
42
+ # @param error_message [String, nil] Error message if failed
43
+ def initialize(image:, model_id:, analysis_type:, caption:, description:, tags:,
44
+ objects:, colors:, text:, raw_response:, started_at:, completed_at:,
45
+ tenant_id:, analyzer_class:, error_class: nil, error_message: nil)
46
+ @image = image
47
+ @model_id = model_id
48
+ @analysis_type = analysis_type
49
+ @caption = caption
50
+ @description = description
51
+ @tags = tags || []
52
+ @objects = objects || []
53
+ @colors = colors || []
54
+ @text = text
55
+ @raw_response = raw_response
56
+ @started_at = started_at
57
+ @completed_at = completed_at
58
+ @tenant_id = tenant_id
59
+ @analyzer_class = analyzer_class
60
+ @error_class = error_class
61
+ @error_message = error_message
62
+ end
63
+
64
+ # Status helpers
65
+
66
+ def success?
67
+ error_class.nil? && (caption.present? || description.present? || tags.any?)
68
+ end
69
+
70
+ def error?
71
+ !success?
72
+ end
73
+
74
+ # Analysis is always single
75
+ def single?
76
+ true
77
+ end
78
+
79
+ def batch?
80
+ false
81
+ end
82
+
83
+ # Count (always 1 for analysis)
84
+ def count
85
+ success? ? 1 : 0
86
+ end
87
+
88
+ # Data access helpers
89
+
90
+ # Check if the result has a caption
91
+ def caption?
92
+ caption.present?
93
+ end
94
+
95
+ # Check if the result has a description
96
+ def description?
97
+ description.present?
98
+ end
99
+
100
+ # Check if the result has tags
101
+ def tags?
102
+ tags.any?
103
+ end
104
+
105
+ # Check if the result has detected objects
106
+ def objects?
107
+ objects.any?
108
+ end
109
+
110
+ # Check if the result has color information
111
+ def colors?
112
+ colors.any?
113
+ end
114
+
115
+ # Check if text was extracted
116
+ def text?
117
+ text.present?
118
+ end
119
+
120
+ # Get tags as symbols
121
+ #
122
+ # @return [Array<Symbol>] Tags as symbols
123
+ def tag_symbols
124
+ tags.map { |t| t.to_s.downcase.gsub(/\s+/, "_").to_sym }
125
+ end
126
+
127
+ # Get the dominant color (highest percentage)
128
+ #
129
+ # @return [Hash, nil] The dominant color or nil
130
+ def dominant_color
131
+ return nil unless colors?
132
+
133
+ colors.max_by { |c| c[:percentage] || 0 }
134
+ end
135
+
136
+ # Get objects by confidence level
137
+ #
138
+ # @param confidence [String] Confidence level ("high", "medium", "low")
139
+ # @return [Array<Hash>] Objects with matching confidence
140
+ def objects_with_confidence(confidence)
141
+ objects.select { |obj| obj[:confidence]&.downcase == confidence.to_s.downcase }
142
+ end
143
+
144
+ # Get high-confidence objects
145
+ #
146
+ # @return [Array<Hash>] High-confidence objects
147
+ def high_confidence_objects
148
+ objects_with_confidence("high")
149
+ end
150
+
151
+ # Check if a specific object was detected
152
+ #
153
+ # @param name [String] Object name to search for
154
+ # @return [Boolean] Whether the object was detected
155
+ def has_object?(name)
156
+ objects.any? { |obj| obj[:name]&.downcase&.include?(name.to_s.downcase) }
157
+ end
158
+
159
+ # Check if a specific tag is present
160
+ #
161
+ # @param tag [String] Tag to search for
162
+ # @return [Boolean] Whether the tag is present
163
+ def has_tag?(tag)
164
+ tags.any? { |t| t.downcase == tag.to_s.downcase }
165
+ end
166
+
167
+ # Timing
168
+
169
+ def duration_ms
170
+ return 0 unless started_at && completed_at
171
+ ((completed_at - started_at) * 1000).round
172
+ end
173
+
174
+ # Cost estimation
175
+
176
+ def total_cost
177
+ return 0 if error?
178
+
179
+ # Analysis typically uses vision model pricing
180
+ # Estimate based on model and image size
181
+ ImageGenerator::Pricing.calculate_cost(
182
+ model_id: model_id,
183
+ count: 1
184
+ )
185
+ end
186
+
187
+ # Serialization
188
+
189
+ def to_h
190
+ {
191
+ success: success?,
192
+ image: image,
193
+ model_id: model_id,
194
+ analysis_type: analysis_type,
195
+ caption: caption,
196
+ description: description,
197
+ tags: tags,
198
+ objects: objects,
199
+ colors: colors,
200
+ text: text,
201
+ total_cost: total_cost,
202
+ duration_ms: duration_ms,
203
+ started_at: started_at&.iso8601,
204
+ completed_at: completed_at&.iso8601,
205
+ tenant_id: tenant_id,
206
+ analyzer_class: analyzer_class,
207
+ error_class: error_class,
208
+ error_message: error_message
209
+ }
210
+ end
211
+
212
+ # Caching
213
+
214
+ def to_cache
215
+ {
216
+ image: image,
217
+ model_id: model_id,
218
+ analysis_type: analysis_type,
219
+ caption: caption,
220
+ description: description,
221
+ tags: tags,
222
+ objects: objects,
223
+ colors: colors,
224
+ text: text,
225
+ total_cost: total_cost,
226
+ cached_at: Time.current.iso8601
227
+ }
228
+ end
229
+
230
+ def self.from_cache(data)
231
+ CachedImageAnalysisResult.new(data)
232
+ end
233
+ end
234
+
235
+ # Lightweight result for cached analyses
236
+ class CachedImageAnalysisResult
237
+ attr_reader :image, :model_id, :analysis_type,
238
+ :caption, :description, :tags, :objects, :colors, :text,
239
+ :total_cost, :cached_at
240
+
241
+ def initialize(data)
242
+ @image = data[:image]
243
+ @model_id = data[:model_id]
244
+ @analysis_type = data[:analysis_type]
245
+ @caption = data[:caption]
246
+ @description = data[:description]
247
+ @tags = data[:tags] || []
248
+ @objects = data[:objects] || []
249
+ @colors = data[:colors] || []
250
+ @text = data[:text]
251
+ @total_cost = data[:total_cost]
252
+ @cached_at = data[:cached_at]
253
+ end
254
+
255
+ def success?
256
+ caption.present? || description.present? || tags.any?
257
+ end
258
+
259
+ def error?
260
+ !success?
261
+ end
262
+
263
+ def cached?
264
+ true
265
+ end
266
+
267
+ def count
268
+ success? ? 1 : 0
269
+ end
270
+
271
+ def single?
272
+ true
273
+ end
274
+
275
+ def batch?
276
+ false
277
+ end
278
+
279
+ def caption?
280
+ caption.present?
281
+ end
282
+
283
+ def description?
284
+ description.present?
285
+ end
286
+
287
+ def tags?
288
+ tags.any?
289
+ end
290
+
291
+ def objects?
292
+ objects.any?
293
+ end
294
+
295
+ def colors?
296
+ colors.any?
297
+ end
298
+
299
+ def text?
300
+ text.present?
301
+ end
302
+
303
+ def tag_symbols
304
+ tags.map { |t| t.to_s.downcase.gsub(/\s+/, "_").to_sym }
305
+ end
306
+
307
+ def dominant_color
308
+ return nil unless colors?
309
+
310
+ colors.max_by { |c| c[:percentage] || 0 }
311
+ end
312
+ end
313
+ end
314
+ end