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
@@ -0,0 +1,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../results/embedding_result"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ # Base class for creating embedding generators
8
+ #
9
+ # Embedder inherits from BaseAgent and uses the middleware pipeline
10
+ # for caching, reliability, instrumentation, and budget controls.
11
+ # Only the core embedding logic is implemented here.
12
+ #
13
+ # @example Basic usage
14
+ # class DocumentEmbedder < RubyLLM::Agents::Embedder
15
+ # model 'text-embedding-3-small'
16
+ # dimensions 512
17
+ # end
18
+ #
19
+ # result = DocumentEmbedder.call(text: "Hello world")
20
+ # result.vector # => [0.123, -0.456, ...]
21
+ #
22
+ # @example Batch processing
23
+ # result = DocumentEmbedder.call(texts: ["Hello", "World"])
24
+ # result.vectors # => [[...], [...]]
25
+ #
26
+ # @example With preprocessing
27
+ # class CleanEmbedder < RubyLLM::Agents::Embedder
28
+ # model 'text-embedding-3-small'
29
+ #
30
+ # def preprocess(text)
31
+ # text.strip.downcase.gsub(/\s+/, ' ')
32
+ # end
33
+ # end
34
+ #
35
+ # @api public
36
+ class Embedder < BaseAgent
37
+ class << self
38
+ # Returns the agent type for embedders
39
+ #
40
+ # @return [Symbol] :embedding
41
+ def agent_type
42
+ :embedding
43
+ end
44
+
45
+ # @!group Embedding-specific DSL
46
+
47
+ # Sets or returns the embedding model
48
+ #
49
+ # Defaults to the embedding model from configuration, not the
50
+ # conversation model that BaseAgent uses.
51
+ #
52
+ # @param value [String, nil] The model identifier to set
53
+ # @return [String] The current model setting
54
+ # @example
55
+ # model "text-embedding-3-large"
56
+ def model(value = nil)
57
+ @model = value if value
58
+ return @model if defined?(@model) && @model
59
+
60
+ # For inheritance: check if parent is also an Embedder
61
+ if superclass.respond_to?(:agent_type) && superclass.agent_type == :embedding
62
+ superclass.model
63
+ else
64
+ default_embedding_model
65
+ end
66
+ end
67
+
68
+ # Sets or returns the vector dimensions
69
+ #
70
+ # Some models (like OpenAI text-embedding-3) support reducing
71
+ # dimensions for more efficient storage.
72
+ #
73
+ # @param value [Integer, nil] The dimensions to set
74
+ # @return [Integer, nil] The current dimensions setting
75
+ # @example
76
+ # dimensions 512
77
+ def dimensions(value = nil)
78
+ @dimensions = value if value
79
+ @dimensions || inherited_or_default(:dimensions, default_embedding_dimensions)
80
+ end
81
+
82
+ # Sets or returns the batch size
83
+ #
84
+ # When embedding multiple texts, they are split into batches
85
+ # of this size for API calls.
86
+ #
87
+ # @param value [Integer, nil] Maximum texts per API call
88
+ # @return [Integer] The current batch size
89
+ # @example
90
+ # batch_size 50
91
+ def batch_size(value = nil)
92
+ @batch_size = value if value
93
+ @batch_size || inherited_or_default(:batch_size, default_embedding_batch_size)
94
+ end
95
+
96
+ # @!endgroup
97
+
98
+ # Executes the embedder with the given parameters
99
+ #
100
+ # @param text [String, nil] Single text to embed
101
+ # @param texts [Array<String>, nil] Multiple texts to embed
102
+ # @param options [Hash] Additional options
103
+ # @yield [batch_result, index] Called after each batch completes
104
+ # @yieldparam batch_result [EmbeddingResult] Result for the batch
105
+ # @yieldparam index [Integer] Batch index (0-based)
106
+ # @return [EmbeddingResult] The embedding result
107
+ # @raise [ArgumentError] If both text: and texts: are provided
108
+ def call(text: nil, texts: nil, **options, &block)
109
+ new(text: text, texts: texts, **options).call(&block)
110
+ end
111
+
112
+ private
113
+
114
+ def inherited_or_default(method, default)
115
+ superclass.respond_to?(method) ? superclass.send(method) : default
116
+ end
117
+
118
+ def default_embedding_model
119
+ RubyLLM::Agents.configuration.default_embedding_model
120
+ rescue StandardError
121
+ "text-embedding-3-small"
122
+ end
123
+
124
+ def default_embedding_dimensions
125
+ RubyLLM::Agents.configuration.default_embedding_dimensions
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def default_embedding_batch_size
131
+ RubyLLM::Agents.configuration.default_embedding_batch_size
132
+ rescue StandardError
133
+ 100
134
+ end
135
+ end
136
+
137
+ # @!attribute [r] text
138
+ # @return [String, nil] Single text to embed
139
+ # @!attribute [r] texts
140
+ # @return [Array<String>, nil] Multiple texts to embed
141
+ attr_reader :text, :texts
142
+
143
+ # Creates a new Embedder instance
144
+ #
145
+ # @param text [String, nil] Single text to embed
146
+ # @param texts [Array<String>, nil] Multiple texts to embed
147
+ # @param options [Hash] Additional options
148
+ def initialize(text: nil, texts: nil, **options)
149
+ @text = text
150
+ @texts = texts
151
+ @batch_block = nil
152
+
153
+ # Set model to embedding model if not specified
154
+ options[:model] ||= self.class.model || self.class.class_eval { default_embedding_model }
155
+
156
+ super(**options)
157
+ end
158
+
159
+ # Executes the embedding through the middleware pipeline
160
+ #
161
+ # @yield [batch_result, index] Called after each batch completes
162
+ # @return [EmbeddingResult] The embedding result
163
+ def call(&block)
164
+ @batch_block = block
165
+ context = build_context
166
+ result_context = Pipeline::Executor.execute(context)
167
+ result_context.output
168
+ end
169
+
170
+ # The input for this embedding operation
171
+ #
172
+ # Used by the pipeline to generate cache keys and for instrumentation.
173
+ #
174
+ # @return [String, Array<String>] The input text(s)
175
+ def user_prompt
176
+ input_texts.join("\n---\n")
177
+ end
178
+
179
+ # Preprocesses text before embedding
180
+ #
181
+ # Override this method in subclasses to apply custom preprocessing
182
+ # like normalization, cleaning, or truncation.
183
+ #
184
+ # @param text [String] The text to preprocess
185
+ # @return [String] The preprocessed text
186
+ # @example Custom preprocessing
187
+ # def preprocess(text)
188
+ # text.strip.downcase.gsub(/\s+/, ' ').truncate(8000)
189
+ # end
190
+ def preprocess(text)
191
+ text
192
+ end
193
+
194
+ # Core embedding execution
195
+ #
196
+ # This is called by the Pipeline::Executor after middleware
197
+ # has been applied. Only contains the embedding API logic.
198
+ #
199
+ # @param context [Pipeline::Context] The execution context
200
+ # @return [void] Sets context.output with the EmbeddingResult
201
+ def execute(context)
202
+ # Track timing internally since middleware sets completed_at after execute returns
203
+ execution_started_at = Time.current
204
+
205
+ input_list = input_texts
206
+ validate_input!(input_list)
207
+
208
+ all_vectors = []
209
+ total_input_tokens = 0
210
+ total_cost = 0.0
211
+ batch_count = resolved_batch_size
212
+
213
+ batches = input_list.each_slice(batch_count).to_a
214
+
215
+ batches.each_with_index do |batch, index|
216
+ batch_result = execute_batch(batch)
217
+
218
+ all_vectors.concat(batch_result[:vectors])
219
+ total_input_tokens += batch_result[:input_tokens] || 0
220
+ total_cost += batch_result[:cost] || 0.0
221
+
222
+ # Yield batch result for progress tracking
223
+ if @batch_block
224
+ batch_embedding_result = build_batch_result(batch_result, batch.size)
225
+ @batch_block.call(batch_embedding_result, index)
226
+ end
227
+ end
228
+
229
+ execution_completed_at = Time.current
230
+ duration_ms = ((execution_completed_at - execution_started_at) * 1000).to_i
231
+
232
+ # Update context with token/cost info
233
+ context.input_tokens = total_input_tokens
234
+ context.output_tokens = 0
235
+ context.input_cost = total_cost
236
+ context.output_cost = 0.0
237
+ context.total_cost = total_cost.round(6)
238
+
239
+ # Build final result
240
+ context.output = build_result(
241
+ vectors: all_vectors,
242
+ input_tokens: total_input_tokens,
243
+ total_cost: total_cost,
244
+ count: input_list.size,
245
+ started_at: context.started_at || execution_started_at,
246
+ completed_at: execution_completed_at,
247
+ duration_ms: duration_ms,
248
+ tenant_id: context.tenant_id
249
+ )
250
+ end
251
+
252
+ # Generates the cache key for this embedding
253
+ #
254
+ # @return [String] Cache key in format "ruby_llm_agents/embedding/..."
255
+ def agent_cache_key
256
+ components = [
257
+ "ruby_llm_agents",
258
+ "embedding",
259
+ self.class.name,
260
+ self.class.version,
261
+ resolved_model,
262
+ resolved_dimensions,
263
+ Digest::SHA256.hexdigest(input_texts.map { |t| preprocess(t) }.join("\n"))
264
+ ].compact
265
+
266
+ components.join("/")
267
+ end
268
+
269
+ protected
270
+
271
+ # Returns the normalized input texts
272
+ #
273
+ # @return [Array<String>] Array of texts to embed
274
+ def input_texts
275
+ @input_texts ||= normalize_input
276
+ end
277
+
278
+ private
279
+
280
+ # Builds context for pipeline execution
281
+ #
282
+ # @return [Pipeline::Context] The context object
283
+ def build_context
284
+ Pipeline::Context.new(
285
+ input: user_prompt,
286
+ agent_class: self.class,
287
+ agent_instance: self,
288
+ model: resolved_model,
289
+ tenant: @options[:tenant],
290
+ skip_cache: @options[:skip_cache]
291
+ )
292
+ end
293
+
294
+ # Normalizes input to an array of texts
295
+ #
296
+ # @return [Array<String>] Array of texts
297
+ # @raise [ArgumentError] If both or neither text/texts provided
298
+ def normalize_input
299
+ if @text && @texts
300
+ raise ArgumentError, "Provide either text: or texts:, not both"
301
+ end
302
+
303
+ if @text.nil? && @texts.nil?
304
+ raise ArgumentError, "Provide either text: or texts:"
305
+ end
306
+
307
+ @texts || [@text]
308
+ end
309
+
310
+ # Validates the input texts
311
+ #
312
+ # @param texts [Array<String>] Texts to validate
313
+ # @raise [ArgumentError] If validation fails
314
+ def validate_input!(texts)
315
+ if texts.empty?
316
+ raise ArgumentError, "texts cannot be empty"
317
+ end
318
+
319
+ texts.each_with_index do |txt, idx|
320
+ unless txt.is_a?(String)
321
+ raise ArgumentError, "texts[#{idx}] must be a String, got #{txt.class}"
322
+ end
323
+
324
+ if txt.empty?
325
+ raise ArgumentError, "texts[#{idx}] cannot be empty"
326
+ end
327
+ end
328
+ end
329
+
330
+ # Executes a single batch of texts
331
+ #
332
+ # @param texts [Array<String>] Texts to embed
333
+ # @return [Hash] Batch result with vectors, tokens, cost
334
+ def execute_batch(texts)
335
+ preprocessed = texts.map { |t| preprocess(t) }
336
+
337
+ embed_options = { model: resolved_model }
338
+ embed_options[:dimensions] = resolved_dimensions if resolved_dimensions
339
+
340
+ response = RubyLLM.embed(preprocessed, **embed_options)
341
+
342
+ # ruby_llm returns vectors as an array (even for single text)
343
+ vectors = response.vectors
344
+ vectors = [vectors] unless vectors.first.is_a?(Array)
345
+
346
+ {
347
+ vectors: vectors,
348
+ input_tokens: response.input_tokens,
349
+ model: response.model,
350
+ cost: calculate_cost(response)
351
+ }
352
+ end
353
+
354
+ # Builds a batch result for progress callback
355
+ #
356
+ # @param batch_data [Hash] Raw batch data
357
+ # @param count [Integer] Number of texts in batch
358
+ # @return [EmbeddingResult] Result for the batch
359
+ def build_batch_result(batch_data, count)
360
+ EmbeddingResult.new(
361
+ vectors: batch_data[:vectors],
362
+ model_id: batch_data[:model],
363
+ dimensions: batch_data[:vectors].first&.size,
364
+ input_tokens: batch_data[:input_tokens],
365
+ total_cost: batch_data[:cost],
366
+ count: count
367
+ )
368
+ end
369
+
370
+ # Builds the final result object
371
+ #
372
+ # @param vectors [Array<Array<Float>>] All vectors
373
+ # @param input_tokens [Integer] Total tokens
374
+ # @param total_cost [Float] Total cost
375
+ # @param count [Integer] Total texts
376
+ # @param started_at [Time] When execution started
377
+ # @param completed_at [Time] When execution completed
378
+ # @param duration_ms [Integer] Execution duration in ms
379
+ # @param tenant_id [String, nil] Tenant identifier
380
+ # @return [EmbeddingResult] The final result
381
+ def build_result(vectors:, input_tokens:, total_cost:, count:, started_at:, completed_at:, duration_ms:, tenant_id:)
382
+ EmbeddingResult.new(
383
+ vectors: vectors,
384
+ model_id: resolved_model,
385
+ dimensions: vectors.first&.size,
386
+ input_tokens: input_tokens,
387
+ total_cost: total_cost,
388
+ duration_ms: duration_ms,
389
+ count: count,
390
+ started_at: started_at,
391
+ completed_at: completed_at,
392
+ tenant_id: tenant_id
393
+ )
394
+ end
395
+
396
+ # Calculates cost for an embedding response
397
+ #
398
+ # @param response [Object] The ruby_llm embedding response
399
+ # @return [Float] Cost in USD
400
+ def calculate_cost(response)
401
+ # ruby_llm may provide cost directly, otherwise estimate
402
+ return response.input_cost if response.respond_to?(:input_cost) && response.input_cost
403
+
404
+ # Fallback: estimate based on tokens and model
405
+ tokens = response.input_tokens || 0
406
+ model_name = response.model.to_s
407
+
408
+ price_per_million = case model_name
409
+ when /text-embedding-3-small/
410
+ 0.02
411
+ when /text-embedding-3-large/
412
+ 0.13
413
+ when /text-embedding-ada/
414
+ 0.10
415
+ else
416
+ 0.02 # Default to small pricing
417
+ end
418
+
419
+ (tokens / 1_000_000.0) * price_per_million
420
+ end
421
+
422
+ # Resolves the model to use
423
+ #
424
+ # @return [String] The model identifier
425
+ def resolved_model
426
+ @model || self.class.model
427
+ end
428
+
429
+ # Resolves the dimensions to use
430
+ #
431
+ # @return [Integer, nil] The dimensions or nil for model default
432
+ def resolved_dimensions
433
+ @options[:dimensions] || self.class.dimensions
434
+ end
435
+
436
+ # Resolves the batch size to use
437
+ #
438
+ # @return [Integer] The batch size
439
+ def resolved_batch_size
440
+ @options[:batch_size] || self.class.batch_size
441
+ end
442
+ end
443
+ end
444
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../results/moderation_result"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ # Standalone moderator for content moderation using the middleware pipeline
8
+ #
9
+ # Provides a class-based interface for moderating content with built-in
10
+ # support for caching, instrumentation, and multi-tenancy through the
11
+ # middleware pipeline.
12
+ #
13
+ # @example Basic usage
14
+ # class ContentModerator < RubyLLM::Agents::Moderator
15
+ # model 'omni-moderation-latest'
16
+ # end
17
+ #
18
+ # result = ContentModerator.call(text: "content to check")
19
+ # result.flagged? # => true/false
20
+ #
21
+ # @example With configuration
22
+ # class StrictModerator < RubyLLM::Agents::Moderator
23
+ # model 'omni-moderation-latest'
24
+ # threshold 0.7
25
+ # categories :hate, :violence, :harassment
26
+ # end
27
+ #
28
+ # @example Runtime override
29
+ # result = ContentModerator.call(
30
+ # text: "content",
31
+ # threshold: 0.9,
32
+ # categories: [:hate]
33
+ # )
34
+ #
35
+ # @api public
36
+ class Moderator < BaseAgent
37
+ class << self
38
+ # Returns the agent type for moderators
39
+ #
40
+ # @return [Symbol] :moderation
41
+ def agent_type
42
+ :moderation
43
+ end
44
+
45
+ # @!group Moderation-specific DSL
46
+
47
+ # Sets or returns the moderation model
48
+ #
49
+ # Defaults to the moderation model from configuration, not the
50
+ # conversation model that BaseAgent uses.
51
+ #
52
+ # @param value [String, nil] Model identifier to set
53
+ # @return [String] Current model setting
54
+ def model(value = nil)
55
+ @model = value if value
56
+ return @model if defined?(@model) && @model
57
+
58
+ # For inheritance: check if parent is also a Moderator
59
+ if superclass.respond_to?(:agent_type) && superclass.agent_type == :moderation
60
+ superclass.model
61
+ else
62
+ default_moderation_model
63
+ end
64
+ end
65
+
66
+ # Sets or returns the score threshold
67
+ #
68
+ # @param value [Float, nil] Threshold value (0.0-1.0)
69
+ # @return [Float, nil] Current threshold
70
+ def threshold(value = nil)
71
+ @threshold = value if value
72
+ @threshold || inherited_or_default(:threshold, nil)
73
+ end
74
+
75
+ # Sets or returns the categories to check
76
+ #
77
+ # @param cats [Array<Symbol>] Category symbols
78
+ # @return [Array<Symbol>, nil] Current categories
79
+ def categories(*cats)
80
+ @categories = cats.flatten.map(&:to_sym) if cats.any?
81
+ @categories || inherited_or_default(:categories, nil)
82
+ end
83
+
84
+ # @!endgroup
85
+
86
+ # Factory method to instantiate and execute moderation
87
+ #
88
+ # @param text [String] Text to moderate
89
+ # @param options [Hash] Runtime options
90
+ # @option options [String] :model Override moderation model
91
+ # @option options [Float] :threshold Override threshold
92
+ # @option options [Array<Symbol>] :categories Override categories
93
+ # @option options [Object] :tenant Tenant for multi-tenancy
94
+ # @return [ModerationResult] The moderation result
95
+ def call(text:, **options)
96
+ new(text: text, **options).call
97
+ end
98
+
99
+ private
100
+
101
+ def inherited_or_default(method, default)
102
+ superclass.respond_to?(method) ? superclass.send(method) : default
103
+ end
104
+
105
+ def default_moderation_model
106
+ RubyLLM::Agents.configuration.default_moderation_model
107
+ rescue StandardError
108
+ "omni-moderation-latest"
109
+ end
110
+ end
111
+
112
+ # @!attribute [r] text
113
+ # @return [String] Text to moderate
114
+ attr_reader :text
115
+
116
+ # Creates a new Moderator instance
117
+ #
118
+ # @param text [String] Text to moderate
119
+ # @param options [Hash] Runtime options
120
+ def initialize(text:, **options)
121
+ @text = text
122
+ @runtime_threshold = options.delete(:threshold)
123
+ @runtime_categories = options.delete(:categories)
124
+
125
+ # Set model to moderation model if not specified
126
+ options[:model] ||= self.class.model
127
+
128
+ super(**options)
129
+ end
130
+
131
+ # Executes the moderation through the middleware pipeline
132
+ #
133
+ # @return [ModerationResult] The moderation result
134
+ def call
135
+ context = build_context
136
+ result_context = Pipeline::Executor.execute(context)
137
+ result_context.output
138
+ end
139
+
140
+ # The input for this moderation operation
141
+ #
142
+ # Used by the pipeline to generate cache keys and for instrumentation.
143
+ #
144
+ # @return [String] The text being moderated
145
+ def user_prompt
146
+ text
147
+ end
148
+
149
+ # Core moderation execution
150
+ #
151
+ # This is called by the Pipeline::Executor after middleware
152
+ # has been applied. Only contains the moderation API logic.
153
+ #
154
+ # @param context [Pipeline::Context] The execution context
155
+ # @return [void] Sets context.output with the ModerationResult
156
+ def execute(context)
157
+ # Track timing internally
158
+ execution_started_at = Time.current
159
+
160
+ moderation_opts = {}
161
+ moderation_opts[:model] = resolved_model if resolved_model
162
+
163
+ raw_result = RubyLLM.moderate(text, **moderation_opts)
164
+
165
+ execution_completed_at = Time.current
166
+ duration_ms = ((execution_completed_at - execution_started_at) * 1000).to_i
167
+
168
+ # Update context with basic info (no tokens for moderation)
169
+ context.input_tokens = 0
170
+ context.output_tokens = 0
171
+ context.total_cost = 0.0
172
+
173
+ # Build final result
174
+ context.output = ModerationResult.new(
175
+ result: raw_result,
176
+ threshold: resolved_threshold,
177
+ categories: resolved_categories
178
+ )
179
+ end
180
+
181
+ # Generates the cache key for this moderation
182
+ #
183
+ # @return [String] Cache key in format "ruby_llm_agents/moderation/..."
184
+ def agent_cache_key
185
+ components = [
186
+ "ruby_llm_agents",
187
+ "moderation",
188
+ self.class.name,
189
+ self.class.version,
190
+ resolved_model,
191
+ resolved_threshold,
192
+ resolved_categories&.sort&.join(","),
193
+ Digest::SHA256.hexdigest(text)
194
+ ].compact
195
+
196
+ components.join("/")
197
+ end
198
+
199
+ private
200
+
201
+ # Builds context for pipeline execution
202
+ #
203
+ # @return [Pipeline::Context] The context object
204
+ def build_context
205
+ Pipeline::Context.new(
206
+ input: user_prompt,
207
+ agent_class: self.class,
208
+ agent_instance: self,
209
+ model: resolved_model,
210
+ tenant: @options[:tenant],
211
+ skip_cache: @options[:skip_cache]
212
+ )
213
+ end
214
+
215
+ # Resolves the model to use
216
+ #
217
+ # @return [String] The model identifier
218
+ def resolved_model
219
+ @model || self.class.model
220
+ end
221
+
222
+ # Resolves the threshold to use
223
+ #
224
+ # @return [Float, nil] The threshold
225
+ def resolved_threshold
226
+ @runtime_threshold || self.class.threshold
227
+ end
228
+
229
+ # Resolves the categories to check
230
+ #
231
+ # @return [Array<Symbol>, nil] The categories
232
+ def resolved_categories
233
+ @runtime_categories || self.class.categories
234
+ end
235
+ end
236
+ end
237
+ end