action_prompter 0.1.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.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ module Adapters
5
+ # A no-op adapter that silently discards all prompts and returns +nil+.
6
+ #
7
+ # Useful for disabling LLM calls in environments where you want to
8
+ # suppress all outbound requests (e.g., a staging environment without
9
+ # API credentials).
10
+ #
11
+ # ActionPrompt.configure do |config|
12
+ # config.adapter = ActionPrompt::Adapters::Null.new
13
+ # end
14
+ class Null < Base
15
+ # @return [nil] always returns nil without making any external calls
16
+ def complete(prompt_text, options = {}) # rubocop:disable Lint/UnusedMethodArgument
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ module Adapters
5
+ # A test adapter that captures deliveries in memory without making real
6
+ # API calls. Use this in your test suite to assert on prompt content.
7
+ #
8
+ # == Setup (spec/support/action_prompt.rb)
9
+ #
10
+ # RSpec.configure do |config|
11
+ # config.before(:each) do
12
+ # ActionPrompt.reset_configuration!
13
+ # ActionPrompt::Adapters::Test.clear_deliveries!
14
+ # end
15
+ # end
16
+ #
17
+ # == Asserting on deliveries
18
+ #
19
+ # it "sends the article body in the prompt" do
20
+ # ArticleSummarizerPrompt.summarize(article).deliver_now
21
+ #
22
+ # delivery = ActionPrompt::Adapters::Test.deliveries.last
23
+ # expect(delivery[:prompt]).to include(article.body)
24
+ # expect(delivery[:options][:model]).to eq("gpt-4o-mini")
25
+ # end
26
+ class Test < Base
27
+ class << self
28
+ # All prompts delivered since the last {clear_deliveries!} call.
29
+ #
30
+ # Each entry is a Hash with keys:
31
+ # - +:prompt+ — the rendered prompt string
32
+ # - +:options+ — the merged LLM options hash
33
+ #
34
+ # @return [Array<Hash>]
35
+ def deliveries
36
+ @deliveries ||= []
37
+ end
38
+
39
+ # Clears all recorded deliveries. Call this in a +before(:each)+ block.
40
+ # @return [void]
41
+ def clear_deliveries!
42
+ @deliveries = []
43
+ end
44
+ end
45
+
46
+ # Records the delivery and returns a predictable canned response string.
47
+ #
48
+ # @param prompt_text [String]
49
+ # @param options [Hash]
50
+ # @return [String]
51
+ def complete(prompt_text, options = {})
52
+ delivery = { prompt: prompt_text, options: options }
53
+ self.class.deliveries << delivery
54
+ "[ActionPrompt::Test] Response for #{options[:model] || "unknown-model"} " \
55
+ "(#{prompt_text.length} chars)"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ # Base class for all prompt classes. Subclass this to define your prompts.
5
+ #
6
+ # Each public instance method in a subclass is a "prompt action". Calling
7
+ # the method at the class level returns an {ActionPrompt::Message} that can
8
+ # be delivered synchronously via {ActionPrompt::Message#deliver_now}.
9
+ #
10
+ # == Defining a prompt
11
+ #
12
+ # class ArticleSummarizerPrompt < ActionPrompt::Base
13
+ # # Class-level defaults override the global config for this class only.
14
+ # default model: "gpt-4o-mini", temperature: 0.5
15
+ #
16
+ # def summarize(article)
17
+ # @article = article # available in the ERB template as @article
18
+ # @word_limit = 150
19
+ #
20
+ # prompt(model: "gpt-4o") # optional per-action override
21
+ # end
22
+ # end
23
+ #
24
+ # == Delivering a prompt
25
+ #
26
+ # response = ArticleSummarizerPrompt.summarize(article).deliver_now
27
+ #
28
+ # == Template location
29
+ #
30
+ # app/views/action_prompts/article_summarizer_prompt/summarize.text.erb
31
+ #
32
+ class Base
33
+ # Inheritable hash of default LLM options for the class.
34
+ # Set via the {.default} class macro.
35
+ class_attribute :default_options, default: {}, instance_accessor: false
36
+
37
+ # ActionView path set populated by the Railtie (or set manually in tests).
38
+ class_attribute :view_paths, default: ActionView::PathSet.new, instance_accessor: false
39
+
40
+ class << self
41
+ # Sets default LLM options for all actions within this class.
42
+ # Options merge with the global config, and can be further overridden
43
+ # per-action by passing options to {#prompt}.
44
+ #
45
+ # @param options [Hash] e.g. +model: "gpt-4o", temperature: 0.7+
46
+ # @return [void]
47
+ #
48
+ # @example
49
+ # class MyPrompt < ActionPrompt::Base
50
+ # default model: "gpt-4o", temperature: 0.3
51
+ # end
52
+ def default(**options)
53
+ self.default_options = default_options.merge(options)
54
+ end
55
+
56
+ # Intercepts calls to public instance methods (prompt actions) and
57
+ # returns an {ActionPrompt::Message}. This is the primary entry point
58
+ # for building a prompt from outside the class.
59
+ #
60
+ # @example
61
+ # ArticleSummarizerPrompt.summarize(article) # => ActionPrompt::Message
62
+ #
63
+ # @return [ActionPrompt::Message]
64
+ def method_missing(method_name, *args, &block)
65
+ if public_method_defined?(method_name)
66
+ instance = new
67
+ instance.send(:process, method_name, *args, &block)
68
+ instance.message
69
+ else
70
+ super
71
+ end
72
+ end
73
+
74
+ def respond_to_missing?(method_name, include_private = false)
75
+ public_method_defined?(method_name) || super
76
+ end
77
+ end
78
+
79
+ # The {ActionPrompt::Message} built by the most recent call to {#prompt}.
80
+ # @return [ActionPrompt::Message, nil]
81
+ attr_reader :message
82
+
83
+ # Declares per-action LLM options and finalises the {Message} for this
84
+ # action. Call this at the end of every action method.
85
+ #
86
+ # Options are merged in increasing priority order:
87
+ # global config defaults < class-level defaults < per-action options
88
+ #
89
+ # @param options [Hash] per-action overrides (e.g. +model:+, +temperature:+)
90
+ # @return [ActionPrompt::Message]
91
+ def prompt(options = {})
92
+ merged_options = ActionPrompt.configuration.default_options
93
+ .merge(self.class.default_options)
94
+ .merge(options)
95
+ @message = Message.new(self, @_action_name, merged_options)
96
+ end
97
+
98
+ private
99
+
100
+ # Runs an action method by name and ensures a {Message} is available.
101
+ #
102
+ # @param method_name [Symbol, String]
103
+ # @param args [Array]
104
+ # @return [ActionPrompt::Message]
105
+ def process(method_name, *args)
106
+ @_action_name = method_name.to_s
107
+ @message = nil
108
+ public_send(method_name, *args)
109
+
110
+ # Guard: if the developer forgot to call `prompt` in their action,
111
+ # build a message with default options so deliver_now still works.
112
+ @message ||= Message.new(self, @_action_name,
113
+ ActionPrompt.configuration.default_options
114
+ .merge(self.class.default_options))
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ # Holds global configuration for the ActionPrompt framework.
5
+ #
6
+ # Configure via an initializer:
7
+ #
8
+ # # config/initializers/action_prompt.rb
9
+ # ActionPrompt.configure do |config|
10
+ # config.adapter = MyOpenAIAdapter.new(api_key: ENV.fetch("OPENAI_API_KEY"))
11
+ # config.default_options = { model: "gpt-4o", temperature: 0.7 }
12
+ # end
13
+ class Configuration
14
+ # The active delivery adapter. Defaults to the Test adapter.
15
+ # @return [ActionPrompt::Adapters::Base]
16
+ attr_accessor :adapter
17
+
18
+ # Global default options passed to every adapter call unless overridden.
19
+ # @return [Hash]
20
+ attr_accessor :default_options
21
+
22
+ # Logger instance. Defaults to Rails.logger inside a Rails app.
23
+ # @return [Logger]
24
+ attr_accessor :logger
25
+
26
+ def initialize
27
+ @adapter = ActionPrompt::Adapters::Test.new
28
+ @default_options = {}
29
+ @logger = default_logger
30
+ end
31
+
32
+ private
33
+
34
+ def default_logger
35
+ defined?(Rails) ? Rails.logger : Logger.new($stdout, level: Logger::INFO)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ # Represents a fully prepared prompt ready for delivery to an LLM adapter.
5
+ # Analogous to +Mail::Message+ in ActionMailer.
6
+ #
7
+ # Instances are returned by calling a class-level action method:
8
+ #
9
+ # message = ArticleSummarizerPrompt.summarize(article)
10
+ # message.body # => rendered ERB string (lazily evaluated)
11
+ # message.deliver_now # => LLM response string
12
+ class Message
13
+ # The prompt instance that built this message.
14
+ # @return [ActionPrompt::Base]
15
+ attr_reader :prompt_instance
16
+
17
+ # The name of the action that built this message.
18
+ # @return [String]
19
+ attr_reader :action_name
20
+
21
+ # Merged LLM options (global defaults < class defaults < per-action options).
22
+ # @return [Hash]
23
+ attr_reader :options
24
+
25
+ # @param prompt_instance [ActionPrompt::Base]
26
+ # @param action_name [String]
27
+ # @param options [Hash]
28
+ def initialize(prompt_instance, action_name, options = {})
29
+ @prompt_instance = prompt_instance
30
+ @action_name = action_name
31
+ @options = options
32
+ end
33
+
34
+ # The rendered prompt body. Evaluated lazily on first access.
35
+ #
36
+ # @return [String]
37
+ def body
38
+ @body ||= Renderer.new(prompt_instance, action_name).render
39
+ end
40
+
41
+ # Delivers the prompt to the configured adapter synchronously and returns
42
+ # the LLM response.
43
+ #
44
+ # @return [String, nil] the LLM's response (type depends on the adapter)
45
+ def deliver_now
46
+ log_delivery
47
+ ActionPrompt.configuration.adapter.complete(body, options)
48
+ end
49
+
50
+ # Enqueues the prompt for asynchronous delivery via ActiveJob.
51
+ #
52
+ # @note Full ActiveJob integration is planned for a future release.
53
+ # This currently raises +NotImplementedError+.
54
+ # @raise [NotImplementedError]
55
+ def deliver_later(**_kwargs)
56
+ raise NotImplementedError,
57
+ "deliver_later is not yet implemented. Use deliver_now. " \
58
+ "ActiveJob integration is planned for a future release."
59
+ end
60
+
61
+ # @return [String]
62
+ def inspect
63
+ "#<ActionPrompt::Message action=#{action_name.inspect} options=#{options.inspect}>"
64
+ end
65
+
66
+ alias to_s inspect
67
+
68
+ private
69
+
70
+ def log_delivery
71
+ logger = ActionPrompt.configuration.logger
72
+ return unless logger
73
+
74
+ logger.debug do
75
+ "[ActionPrompt] Delivering #{prompt_instance.class.name}##{action_name} " \
76
+ "via #{ActionPrompt.configuration.adapter.class.name} " \
77
+ "with options #{options.inspect}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActionPrompt
6
+ class Railtie < Rails::Railtie
7
+ # Ensure app/prompts is part of the autoload paths so prompt classes
8
+ # are loaded by Zeitwerk without any extra configuration.
9
+ initializer "action_prompt.add_autoload_paths" do |app|
10
+ prompts_path = app.root.join("app", "prompts").to_s
11
+
12
+ app.config.autoload_paths << prompts_path
13
+ app.config.eager_load_paths << prompts_path
14
+ end
15
+
16
+ # Point ActionPrompt::Base's view paths at the application's view directory
17
+ # so ERB templates resolve correctly from app/views/action_prompts/.
18
+ initializer "action_prompt.configure_view_paths" do |app|
19
+ ActiveSupport.on_load(:action_prompt) do
20
+ ActionPrompt::Base.view_paths = ActionView::PathSet.new(
21
+ [app.root.join("app", "views").to_s]
22
+ )
23
+ end
24
+ end
25
+
26
+ # Use Rails.logger once the app is fully initialized.
27
+ initializer "action_prompt.set_logger" do
28
+ ActiveSupport.on_load(:action_prompt) do
29
+ ActionPrompt.configuration.logger = Rails.logger
30
+ end
31
+ end
32
+
33
+ # Allow prompt classes to call run_load_hooks in the future.
34
+ ActiveSupport.run_load_hooks(:action_prompt, Base)
35
+ end
36
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ # Handles ERB template rendering for prompt actions via ActionView.
5
+ #
6
+ # Templates are resolved from the view paths configured on the prompt class
7
+ # (defaults to +app/views/+ in a Rails app). The lookup path convention is:
8
+ #
9
+ # app/views/action_prompts/<prompt_class_name>/<action_name>.text.erb
10
+ #
11
+ # == Examples
12
+ #
13
+ # ArticleSummarizerPrompt -> action_prompts/article_summarizer_prompt/summarize.text.erb
14
+ # Admin::ReportPrompt -> action_prompts/admin/report_prompt/generate.text.erb
15
+ class Renderer
16
+ # @param prompt_instance [ActionPrompt::Base] owns the instance variables for the template
17
+ # @param action_name [String] the prompt action method name
18
+ def initialize(prompt_instance, action_name)
19
+ @prompt_instance = prompt_instance
20
+ @action_name = action_name
21
+ end
22
+
23
+ # Renders the ERB template and returns the resulting string.
24
+ #
25
+ # @return [String] the rendered prompt body
26
+ # @raise [ActionView::MissingTemplate] if the template file cannot be found
27
+ def render
28
+ lookup_context = ActionView::LookupContext.new(resolved_view_paths)
29
+ view = ActionView::Base.with_empty_template_cache.new(lookup_context, assigns, nil)
30
+ view.render(template: template_path, formats: [:text], handlers: [:erb])
31
+ end
32
+
33
+ private
34
+
35
+ # Derives the template path from the prompt class name and action.
36
+ #
37
+ # @return [String] e.g. "action_prompts/article_summarizer_prompt/summarize"
38
+ def template_path
39
+ class_segment = @prompt_instance.class.name
40
+ .gsub("::", "/")
41
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
42
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
43
+ .downcase
44
+
45
+ "action_prompts/#{class_segment}/#{@action_name}"
46
+ end
47
+
48
+ # Returns the view paths configured on the prompt class, falling back to
49
+ # the Rails app root or the current working directory.
50
+ #
51
+ # @return [Array<String>]
52
+ def resolved_view_paths
53
+ class_paths = @prompt_instance.class.view_paths.to_a.map(&:to_s)
54
+ return class_paths unless class_paths.empty?
55
+
56
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
57
+ [Rails.root.join("app", "views").to_s]
58
+ else
59
+ [File.expand_path("app/views", Dir.pwd)]
60
+ end
61
+ end
62
+
63
+ # Collects public instance variables from the prompt, exposing them
64
+ # to the ERB template as top-level assigns.
65
+ #
66
+ # Variables prefixed with +_+ or named +message+ are excluded.
67
+ #
68
+ # @return [Hash{String => Object}]
69
+ def assigns
70
+ @prompt_instance.instance_variables.each_with_object({}) do |ivar, hash|
71
+ name = ivar.to_s.delete_prefix("@")
72
+ next if name.start_with?("_") || name == "message"
73
+
74
+ hash[name] = @prompt_instance.instance_variable_get(ivar)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionPrompt
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/class/attribute"
5
+ require "active_support/core_ext/string/inflections"
6
+ require "action_view"
7
+
8
+ require_relative "action_prompt/version"
9
+ require_relative "action_prompt/adapters/base"
10
+ require_relative "action_prompt/adapters/null"
11
+ require_relative "action_prompt/adapters/test"
12
+ require_relative "action_prompt/configuration"
13
+ require_relative "action_prompt/renderer"
14
+ require_relative "action_prompt/message"
15
+ require_relative "action_prompt/base"
16
+
17
+ # ActionPrompt is an ActionMailer-inspired framework for defining, rendering,
18
+ # and delivering LLM prompts in Ruby on Rails applications.
19
+ #
20
+ # == Configuration
21
+ #
22
+ # ActionPrompt.configure do |config|
23
+ # config.adapter = MyAdapter.new(api_key: ENV.fetch("MY_API_KEY"))
24
+ # config.default_options = { model: "gpt-4o", temperature: 0.7 }
25
+ # end
26
+ #
27
+ # == Delivery
28
+ #
29
+ # response = ArticleSummarizerPrompt.summarize(article).deliver_now
30
+ #
31
+ module ActionPrompt
32
+ class << self
33
+ # Returns the global configuration object.
34
+ # @return [ActionPrompt::Configuration]
35
+ def configuration
36
+ @configuration ||= Configuration.new
37
+ end
38
+
39
+ # Yields the {Configuration} for block-style setup.
40
+ # @yieldparam config [ActionPrompt::Configuration]
41
+ # @return [void]
42
+ def configure
43
+ yield configuration
44
+ end
45
+
46
+ # Resets configuration to defaults. Primarily useful in test suites.
47
+ # @return [void]
48
+ def reset_configuration!
49
+ @configuration = Configuration.new
50
+ end
51
+ end
52
+ end
53
+
54
+ require_relative "action_prompt/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+
6
+ module ActionPrompt
7
+ # Rails generator that creates a prompt class and its ERB view templates.
8
+ #
9
+ # == Usage
10
+ #
11
+ # rails generate action_prompt NAME [action action ...]
12
+ #
13
+ # == Examples
14
+ #
15
+ # rails generate action_prompt ArticleSummarizer summarize
16
+ # rails generate action_prompt Moderation classify flag
17
+ # rails generate action_prompt Admin::Report generate
18
+ #
19
+ # Each invocation creates:
20
+ # * app/prompts/<name>_prompt.rb
21
+ # * app/views/action_prompts/<name>_prompt/<action>.text.erb (one per action)
22
+ class ActionPromptGenerator < Rails::Generators::NamedBase
23
+ source_root File.expand_path("templates", __dir__)
24
+
25
+ argument :actions,
26
+ type: :array,
27
+ default: [],
28
+ banner: "action action ..."
29
+
30
+ desc "Creates an ActionPrompt prompt class and ERB view template(s)."
31
+
32
+ # ------------------------------------------------------------------ #
33
+ # Steps
34
+ # ------------------------------------------------------------------ #
35
+
36
+ def create_prompt_file
37
+ template "prompt.rb.tt",
38
+ File.join("app/prompts", class_path, "#{file_name}_prompt.rb")
39
+ end
40
+
41
+ def create_view_files
42
+ actions.each do |action|
43
+ @current_action = action
44
+ template "prompt.text.erb.tt",
45
+ File.join("app/views/action_prompts", class_path,
46
+ "#{file_name}_prompt", "#{action}.text.erb")
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Full CamelCase class name including the "Prompt" suffix.
53
+ # @return [String] e.g. "ArticleSummarizerPrompt"
54
+ def prompt_class_name
55
+ "#{class_name}Prompt"
56
+ end
57
+
58
+ attr_reader :current_action
59
+ end
60
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= prompt_class_name %> < ActionPrompt::Base
4
+ # Set class-level defaults. These override the global config for this class.
5
+ # default model: "gpt-4o-mini", temperature: 0.7
6
+ <% actions.each do |action| -%>
7
+
8
+ def <%= action %>
9
+ # Assign instance variables here; they will be available in the ERB template.
10
+ # e.g. @record = some_argument
11
+
12
+ prompt(
13
+ # model: "gpt-4o-mini",
14
+ # temperature: 0.7
15
+ )
16
+ end
17
+ <% end -%>
18
+ end
@@ -0,0 +1,13 @@
1
+ <%# app/views/action_prompts/<%= file_name %>_prompt/<%= current_action %>.text.erb %>
2
+ <%# Instance variables set in <%= prompt_class_name %>#<%= current_action %> are available here. %>
3
+
4
+ You are a helpful assistant.
5
+
6
+ <%# TODO: Write your prompt below, referencing instance variables as needed. %>
7
+ <%# Example:
8
+ Please summarize the following content in three bullet points:
9
+
10
+ Title: <%= @record.title %>
11
+
12
+ <%= @record.body %>
13
+ %>