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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +586 -0
- data/lib/action_prompt/adapters/base.rb +62 -0
- data/lib/action_prompt/adapters/null.rb +21 -0
- data/lib/action_prompt/adapters/test.rb +59 -0
- data/lib/action_prompt/base.rb +117 -0
- data/lib/action_prompt/configuration.rb +38 -0
- data/lib/action_prompt/message.rb +81 -0
- data/lib/action_prompt/railtie.rb +36 -0
- data/lib/action_prompt/renderer.rb +78 -0
- data/lib/action_prompt/version.rb +5 -0
- data/lib/action_prompt.rb +54 -0
- data/lib/generators/action_prompt/action_prompt_generator.rb +60 -0
- data/lib/generators/action_prompt/templates/prompt.rb.tt +18 -0
- data/lib/generators/action_prompt/templates/prompt.text.erb.tt +13 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2e39b2cf1925be44f693de2d977ffadb9696388b05e7453b36c3e8340d0e79eb
|
|
4
|
+
data.tar.gz: fb7bc10ee65be65492023a52bc28566c3f56ea4a3d6fb872e6066f1ae004d9a4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f819ed0a124a05367a6f2f5ddde73ade099299ac5083be5da5ea4fb0bc2d9fa9ed8d1c919a7c4c9d901384758087a00235e30aeee0d8f02ff45f2956d1847837
|
|
7
|
+
data.tar.gz: c80fd8a2c9edea9ae85e935f06a8a30b327c3a98224e5dfb6b9f8221ede0abf87969146a5860b8b4f270a4acefa37a7a49fb8ce73724ad415d5c30ed6ce35730
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-02-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Published as `action_prompter` gem (`require "action_prompt"`, module `ActionPrompt`)
|
|
15
|
+
- `ActionPrompt::Base` with ActionMailer-inspired DSL for defining prompt actions
|
|
16
|
+
- ERB template rendering via ActionView (templates in `app/views/action_prompts/`)
|
|
17
|
+
- Pluggable adapter system (`ActionPrompt::Adapters::Base`)
|
|
18
|
+
- Built-in `Test` adapter for use in test suites (captures deliveries in memory)
|
|
19
|
+
- Built-in `Null` adapter for suppressing LLM calls in specific environments
|
|
20
|
+
- Global configuration via `ActionPrompt.configure`
|
|
21
|
+
- Per-class default options via `.default(model:, temperature:, ...)`
|
|
22
|
+
- `ActionPrompt::Message` with `deliver_now` for synchronous delivery
|
|
23
|
+
- Rails generator: `rails generate action_prompt NAME [action action ...]`
|
|
24
|
+
- `Railtie` for automatic integration into Rails apps
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# ActionPrompt
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/action_prompt)
|
|
4
|
+
[](https://github.com/soran-me/action_prompt/actions)
|
|
5
|
+
|
|
6
|
+
**ActionPrompt** brings Rails-native conventions to Large Language Model (LLM) integration. Inspired by ActionMailer, it lets you define prompt classes with structured actions, write prompts as ERB templates, and deliver them to any LLM provider through a pluggable adapter system.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# app/prompts/article_summarizer_prompt.rb
|
|
10
|
+
class ArticleSummarizerPrompt < ActionPrompt::Base
|
|
11
|
+
default model: "gpt-4o-mini", temperature: 0.5
|
|
12
|
+
|
|
13
|
+
def summarize(article)
|
|
14
|
+
@article = article
|
|
15
|
+
prompt
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# app/views/action_prompts/article_summarizer_prompt/summarize.text.erb
|
|
20
|
+
Summarize the following article in three concise bullet points.
|
|
21
|
+
|
|
22
|
+
Title: <%= @article.title %>
|
|
23
|
+
|
|
24
|
+
<%= @article.body %>
|
|
25
|
+
|
|
26
|
+
# Anywhere in your app
|
|
27
|
+
response = ArticleSummarizerPrompt.summarize(article).deliver_now
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Table of Contents
|
|
33
|
+
|
|
34
|
+
- [Installation](#installation)
|
|
35
|
+
- [Configuration](#configuration)
|
|
36
|
+
- [Core Concepts](#core-concepts)
|
|
37
|
+
- [Quick Start](#quick-start)
|
|
38
|
+
- [Generator](#generator)
|
|
39
|
+
- [Writing Templates](#writing-templates)
|
|
40
|
+
- [Default Options](#default-options)
|
|
41
|
+
- [Adapters](#adapters)
|
|
42
|
+
- [Writing an OpenAI Adapter](#writing-an-openai-adapter)
|
|
43
|
+
- [Writing a Google Gemini Adapter](#writing-a-google-gemini-adapter)
|
|
44
|
+
- [Testing](#testing)
|
|
45
|
+
- [Advanced Usage](#advanced-usage)
|
|
46
|
+
- [Contributing](#contributing)
|
|
47
|
+
- [License](#license)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
Add to your `Gemfile`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
gem "action_prompter"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then run:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bundle install
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
ActionPrompt requires **Ruby 3.1+** and **Rails 7.0+**.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Create an initializer:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# config/initializers/action_prompt.rb
|
|
75
|
+
|
|
76
|
+
ActionPrompt.configure do |config|
|
|
77
|
+
# Set the delivery adapter (required for real LLM calls)
|
|
78
|
+
config.adapter = MyOpenAIAdapter.new(api_key: ENV.fetch("OPENAI_API_KEY"))
|
|
79
|
+
|
|
80
|
+
# Global defaults applied to every prompt unless overridden
|
|
81
|
+
config.default_options = {
|
|
82
|
+
model: "gpt-4o",
|
|
83
|
+
temperature: 0.7
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
In development/test, the gem ships with two built-in adapters so you can work
|
|
89
|
+
without live API credentials:
|
|
90
|
+
|
|
91
|
+
| Adapter | Behaviour |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `ActionPrompt::Adapters::Test` | Records deliveries in memory (default) |
|
|
94
|
+
| `ActionPrompt::Adapters::Null` | Silently discards all prompts, returns `nil` |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Core Concepts
|
|
99
|
+
|
|
100
|
+
| Concept | ActionMailer analogy | ActionPrompt equivalent |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| Prompt class | Mailer class | `ApplicationPrompt < ActionPrompt::Base` |
|
|
103
|
+
| Action method | `welcome_email(user)` | `summarize(article)` |
|
|
104
|
+
| Template | `welcome_email.html.erb` | `summarize.text.erb` |
|
|
105
|
+
| Message object | `Mail::Message` | `ActionPrompt::Message` |
|
|
106
|
+
| Delivery | `deliver_now` | `deliver_now` |
|
|
107
|
+
| Adapter | `smtp_settings` | `config.adapter = MyAdapter.new` |
|
|
108
|
+
|
|
109
|
+
### Flow
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
YourPrompt.action(args) # class-level call
|
|
113
|
+
└─► ActionPrompt::Base # creates instance, calls action method
|
|
114
|
+
└─► #prompt(options) # sets options, returns Message
|
|
115
|
+
└─► Message # lazily renders ERB template
|
|
116
|
+
└─► #deliver_now
|
|
117
|
+
└─► Adapter#complete(body, options) # → LLM → response
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Quick Start
|
|
123
|
+
|
|
124
|
+
### 1. Create the prompt class
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# app/prompts/article_summarizer_prompt.rb
|
|
128
|
+
|
|
129
|
+
class ArticleSummarizerPrompt < ActionPrompt::Base
|
|
130
|
+
# Class-level defaults — override global config for this class only.
|
|
131
|
+
default model: "gpt-4o-mini", temperature: 0.5
|
|
132
|
+
|
|
133
|
+
# Each public method is a "prompt action".
|
|
134
|
+
# Set instance variables here; they become available in the ERB template.
|
|
135
|
+
def summarize(article)
|
|
136
|
+
@article = article
|
|
137
|
+
@word_limit = 150
|
|
138
|
+
|
|
139
|
+
# Call `prompt` last. Pass per-action overrides if needed.
|
|
140
|
+
prompt(model: "gpt-4o")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### 2. Create the ERB template
|
|
146
|
+
|
|
147
|
+
```erb
|
|
148
|
+
<%# app/views/action_prompts/article_summarizer_prompt/summarize.text.erb %>
|
|
149
|
+
|
|
150
|
+
You are an expert editorial assistant. Summarize the article below in no more
|
|
151
|
+
than <%= @word_limit %> words, using three concise bullet points.
|
|
152
|
+
|
|
153
|
+
## Article
|
|
154
|
+
|
|
155
|
+
Title: <%= @article.title %>
|
|
156
|
+
Author: <%= @article.author.name %>
|
|
157
|
+
Published: <%= @article.published_at.strftime("%B %-d, %Y") %>
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
<%= @article.body %>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### 3. Deliver
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# In a controller, background job, or anywhere else:
|
|
168
|
+
article = Article.find(params[:id])
|
|
169
|
+
response = ArticleSummarizerPrompt.summarize(article).deliver_now
|
|
170
|
+
|
|
171
|
+
render json: { summary: response }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Generator
|
|
177
|
+
|
|
178
|
+
The generator creates both the prompt class and the view template(s) in one
|
|
179
|
+
command:
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
rails generate action_prompt NAME [action action ...]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Example
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
rails generate action_prompt ArticleSummarizer summarize
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Generates:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
create app/prompts/article_summarizer_prompt.rb
|
|
195
|
+
create app/views/action_prompts/article_summarizer_prompt/summarize.text.erb
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Multiple actions
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
rails generate action_prompt Moderation classify flag summarize
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Generates one class with three stubbed action methods and three view templates.
|
|
205
|
+
|
|
206
|
+
### Namespaced prompts
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
rails generate action_prompt Admin::Report generate
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Generates:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
create app/prompts/admin/report_prompt.rb
|
|
216
|
+
create app/views/action_prompts/admin/report_prompt/generate.text.erb
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Writing Templates
|
|
222
|
+
|
|
223
|
+
Templates live in `app/views/action_prompts/` and follow the naming convention:
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
app/views/action_prompts/<prompt_class_underscored>/<action_name>.text.erb
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
All instance variables set in the action method are available in the template.
|
|
230
|
+
Standard ERB interpolation, conditionals, and partials all work.
|
|
231
|
+
|
|
232
|
+
```erb
|
|
233
|
+
<%# app/views/action_prompts/article_summarizer_prompt/summarize.text.erb %>
|
|
234
|
+
|
|
235
|
+
You are a helpful assistant specialised in content summarisation.
|
|
236
|
+
|
|
237
|
+
<% if @article.paywalled? %>
|
|
238
|
+
Note: this article is paywalled; focus only on the excerpt provided.
|
|
239
|
+
<% end %>
|
|
240
|
+
|
|
241
|
+
Please summarise the following content:
|
|
242
|
+
|
|
243
|
+
<%= @article.excerpt %>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### System prompts
|
|
247
|
+
|
|
248
|
+
A common pattern is to split system and user content into separate template
|
|
249
|
+
"sections" within a single file using a plain delimiter:
|
|
250
|
+
|
|
251
|
+
```erb
|
|
252
|
+
---SYSTEM---
|
|
253
|
+
You are an expert Ruby on Rails developer. Answer questions concisely.
|
|
254
|
+
|
|
255
|
+
---USER---
|
|
256
|
+
<%= @question %>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Your adapter can then parse the delimiter and map sections to the LLM's
|
|
260
|
+
`system` / `user` message roles.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Default Options
|
|
265
|
+
|
|
266
|
+
Options are merged in increasing precedence:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
global config < class default < per-action option
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# Global (lowest priority)
|
|
274
|
+
ActionPrompt.configure { |c| c.default_options = { model: "gpt-3.5-turbo", temperature: 0.9 } }
|
|
275
|
+
|
|
276
|
+
class MyPrompt < ActionPrompt::Base
|
|
277
|
+
# Class default (overrides global)
|
|
278
|
+
default model: "gpt-4o", max_tokens: 1024
|
|
279
|
+
|
|
280
|
+
def draft(brief)
|
|
281
|
+
@brief = brief
|
|
282
|
+
# Per-action (overrides class default — highest priority)
|
|
283
|
+
prompt(temperature: 0.2)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Effective options for draft():
|
|
288
|
+
# { model: "gpt-4o", temperature: 0.2, max_tokens: 1024 }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Adapters
|
|
294
|
+
|
|
295
|
+
An adapter is any object that inherits from `ActionPrompt::Adapters::Base` and
|
|
296
|
+
implements the `#complete` method:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
# @param prompt_text [String] the fully rendered prompt string
|
|
300
|
+
# @param options [Hash] merged LLM options (model, temperature, …)
|
|
301
|
+
# @return [String] the LLM response text
|
|
302
|
+
def complete(prompt_text, options = {})
|
|
303
|
+
raise NotImplementedError
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Writing an OpenAI Adapter
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# lib/my_app/adapters/open_ai_adapter.rb
|
|
311
|
+
|
|
312
|
+
# Gemfile: gem "ruby-openai"
|
|
313
|
+
require "openai"
|
|
314
|
+
|
|
315
|
+
module MyApp
|
|
316
|
+
module Adapters
|
|
317
|
+
class OpenAIAdapter < ActionPrompt::Adapters::Base
|
|
318
|
+
def initialize(api_key:)
|
|
319
|
+
@client = OpenAI::Client.new(access_token: api_key)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def complete(prompt_text, options = {})
|
|
323
|
+
response = @client.chat(
|
|
324
|
+
parameters: {
|
|
325
|
+
model: options.fetch(:model, "gpt-4o"),
|
|
326
|
+
temperature: options.fetch(:temperature, 0.7),
|
|
327
|
+
max_tokens: options[:max_tokens],
|
|
328
|
+
messages: [{ role: "user", content: prompt_text }]
|
|
329
|
+
}.compact
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
response.dig("choices", 0, "message", "content")
|
|
333
|
+
rescue OpenAI::Error => e
|
|
334
|
+
raise ActionPrompt::DeliveryError, "OpenAI error: #{e.message}"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Register in your initializer:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
# config/initializers/action_prompt.rb
|
|
345
|
+
ActionPrompt.configure do |config|
|
|
346
|
+
config.adapter = MyApp::Adapters::OpenAIAdapter.new(
|
|
347
|
+
api_key: ENV.fetch("OPENAI_API_KEY")
|
|
348
|
+
)
|
|
349
|
+
config.default_options = { model: "gpt-4o", temperature: 0.7 }
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Writing a Google Gemini Adapter
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
# lib/my_app/adapters/gemini_adapter.rb
|
|
357
|
+
|
|
358
|
+
# Gemfile: gem "gemini-ai"
|
|
359
|
+
require "gemini-ai"
|
|
360
|
+
|
|
361
|
+
module MyApp
|
|
362
|
+
module Adapters
|
|
363
|
+
class GeminiAdapter < ActionPrompt::Adapters::Base
|
|
364
|
+
def initialize(api_key:, default_model: "gemini-1.5-pro")
|
|
365
|
+
@default_model = default_model
|
|
366
|
+
@api_key = api_key
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def complete(prompt_text, options = {})
|
|
370
|
+
model = options.fetch(:model, @default_model)
|
|
371
|
+
client = Gemini.new(
|
|
372
|
+
credentials: { service: "generative-language-api", api_key: @api_key },
|
|
373
|
+
options: { model: model, server_sent_events: false }
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
result = client.generate_content(
|
|
377
|
+
{
|
|
378
|
+
contents: { role: "user", parts: { text: prompt_text } },
|
|
379
|
+
generationConfig: {
|
|
380
|
+
temperature: options[:temperature],
|
|
381
|
+
maxOutputTokens: options[:max_tokens]
|
|
382
|
+
}.compact
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
result.dig("candidates", 0, "content", "parts", 0, "text")
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Register in your initializer:
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
ActionPrompt.configure do |config|
|
|
397
|
+
config.adapter = MyApp::Adapters::GeminiAdapter.new(
|
|
398
|
+
api_key: ENV.fetch("GOOGLE_AI_API_KEY"),
|
|
399
|
+
default_model: "gemini-1.5-flash"
|
|
400
|
+
)
|
|
401
|
+
end
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Testing
|
|
407
|
+
|
|
408
|
+
ActionPrompt ships with `ActionPrompt::Adapters::Test`, which captures all
|
|
409
|
+
deliveries in memory without making real API calls.
|
|
410
|
+
|
|
411
|
+
### Setup
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
# spec/support/action_prompt.rb
|
|
415
|
+
|
|
416
|
+
RSpec.configure do |config|
|
|
417
|
+
config.before do
|
|
418
|
+
ActionPrompt.reset_configuration!
|
|
419
|
+
ActionPrompt.configure do |c|
|
|
420
|
+
c.adapter = ActionPrompt::Adapters::Test.new
|
|
421
|
+
end
|
|
422
|
+
ActionPrompt::Adapters::Test.clear_deliveries!
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Require this file from `spec/rails_helper.rb`:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
require "support/action_prompt"
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Writing tests
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
# spec/prompts/article_summarizer_prompt_spec.rb
|
|
437
|
+
|
|
438
|
+
RSpec.describe ArticleSummarizerPrompt do
|
|
439
|
+
let(:article) do
|
|
440
|
+
double(:article,
|
|
441
|
+
title: "The Future of Rails",
|
|
442
|
+
body: "Rails continues to evolve...",
|
|
443
|
+
author: double(name: "DHH"),
|
|
444
|
+
paywalled?: false,
|
|
445
|
+
published_at: Date.today,
|
|
446
|
+
excerpt: "Rails continues to evolve..."
|
|
447
|
+
)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
describe ".summarize" do
|
|
451
|
+
it "returns an ActionPrompt::Message" do
|
|
452
|
+
message = described_class.summarize(article)
|
|
453
|
+
expect(message).to be_an(ActionPrompt::Message)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it "uses the configured model" do
|
|
457
|
+
described_class.summarize(article).deliver_now
|
|
458
|
+
options = ActionPrompt::Adapters::Test.deliveries.last[:options]
|
|
459
|
+
expect(options[:model]).to eq("gpt-4o")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
it "includes the article title in the rendered prompt" do
|
|
463
|
+
described_class.summarize(article).deliver_now
|
|
464
|
+
prompt = ActionPrompt::Adapters::Test.deliveries.last[:prompt]
|
|
465
|
+
expect(prompt).to include("The Future of Rails")
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "includes the word limit in the rendered prompt" do
|
|
469
|
+
described_class.summarize(article).deliver_now
|
|
470
|
+
prompt = ActionPrompt::Adapters::Test.deliveries.last[:prompt]
|
|
471
|
+
expect(prompt).to include("150")
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Testing the adapter in isolation
|
|
478
|
+
|
|
479
|
+
```ruby
|
|
480
|
+
RSpec.describe ArticleSummarizerPrompt do
|
|
481
|
+
it "calls the adapter with the rendered body" do
|
|
482
|
+
adapter = instance_double(MyApp::Adapters::OpenAIAdapter, complete: "Summary text.")
|
|
483
|
+
ActionPrompt.configure { |c| c.adapter = adapter }
|
|
484
|
+
|
|
485
|
+
described_class.summarize(article).deliver_now
|
|
486
|
+
|
|
487
|
+
expect(adapter).to have_received(:complete)
|
|
488
|
+
.with(include("The Future of Rails"), hash_including(model: "gpt-4o"))
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Advanced Usage
|
|
496
|
+
|
|
497
|
+
### Accessing the raw message body before delivery
|
|
498
|
+
|
|
499
|
+
```ruby
|
|
500
|
+
message = ArticleSummarizerPrompt.summarize(article)
|
|
501
|
+
puts message.body # renders the ERB and returns the string
|
|
502
|
+
puts message.options # { model: "gpt-4o", temperature: 0.5 }
|
|
503
|
+
response = message.deliver_now
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### Extracting a base prompt class for your app
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
# app/prompts/application_prompt.rb
|
|
510
|
+
class ApplicationPrompt < ActionPrompt::Base
|
|
511
|
+
default model: "gpt-4o-mini", temperature: 0.7
|
|
512
|
+
|
|
513
|
+
private
|
|
514
|
+
|
|
515
|
+
# Shared helpers available in all subclass action methods.
|
|
516
|
+
def current_date
|
|
517
|
+
Date.today.strftime("%B %-d, %Y")
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
class ArticleSummarizerPrompt < ApplicationPrompt
|
|
524
|
+
def summarize(article)
|
|
525
|
+
@article = article
|
|
526
|
+
@date = current_date # available from ApplicationPrompt
|
|
527
|
+
prompt
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Conditional adapter switching per environment
|
|
533
|
+
|
|
534
|
+
```ruby
|
|
535
|
+
# config/initializers/action_prompt.rb
|
|
536
|
+
ActionPrompt.configure do |config|
|
|
537
|
+
config.adapter =
|
|
538
|
+
case Rails.env
|
|
539
|
+
when "production" then MyApp::Adapters::OpenAIAdapter.new(api_key: ENV.fetch("OPENAI_API_KEY"))
|
|
540
|
+
when "development" then ActionPrompt::Adapters::Null.new # save API credits locally
|
|
541
|
+
when "test" then ActionPrompt::Adapters::Test.new
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## Project Structure
|
|
549
|
+
|
|
550
|
+
```
|
|
551
|
+
your_rails_app/
|
|
552
|
+
├── app/
|
|
553
|
+
│ ├── prompts/
|
|
554
|
+
│ │ └── article_summarizer_prompt.rb # Prompt class
|
|
555
|
+
│ └── views/
|
|
556
|
+
│ └── action_prompts/
|
|
557
|
+
│ └── article_summarizer_prompt/
|
|
558
|
+
│ └── summarize.text.erb # ERB template
|
|
559
|
+
├── config/
|
|
560
|
+
│ └── initializers/
|
|
561
|
+
│ └── action_prompt.rb # Adapter + global config
|
|
562
|
+
└── spec/
|
|
563
|
+
├── prompts/
|
|
564
|
+
│ └── article_summarizer_prompt_spec.rb
|
|
565
|
+
└── support/
|
|
566
|
+
└── action_prompt.rb # Test setup
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Contributing
|
|
572
|
+
|
|
573
|
+
1. Fork the repository
|
|
574
|
+
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
575
|
+
3. Make your changes and add tests
|
|
576
|
+
4. Ensure all tests pass (`bundle exec rspec`) and the linter is happy (`bundle exec rubocop`)
|
|
577
|
+
5. Open a Pull Request
|
|
578
|
+
|
|
579
|
+
Bug reports and feature requests are welcome at
|
|
580
|
+
[https://github.com/soran-me/action_prompt/issues](https://github.com/soran-me/action_prompt/issues).
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## License
|
|
585
|
+
|
|
586
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionPrompt
|
|
4
|
+
module Adapters
|
|
5
|
+
# Abstract base class for all ActionPrompt delivery adapters.
|
|
6
|
+
#
|
|
7
|
+
# Implement a custom adapter by subclassing this and overriding {#complete}.
|
|
8
|
+
#
|
|
9
|
+
# == OpenAI Example
|
|
10
|
+
#
|
|
11
|
+
# # Gemfile: gem "ruby-openai"
|
|
12
|
+
#
|
|
13
|
+
# class OpenAIAdapter < ActionPrompt::Adapters::Base
|
|
14
|
+
# def initialize(api_key:)
|
|
15
|
+
# @client = OpenAI::Client.new(access_token: api_key)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def complete(prompt_text, options = {})
|
|
19
|
+
# response = @client.chat(
|
|
20
|
+
# parameters: {
|
|
21
|
+
# model: options.fetch(:model, "gpt-4o"),
|
|
22
|
+
# temperature: options.fetch(:temperature, 0.7),
|
|
23
|
+
# messages: [{ role: "user", content: prompt_text }]
|
|
24
|
+
# }
|
|
25
|
+
# )
|
|
26
|
+
# response.dig("choices", 0, "message", "content")
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# == Google Gemini Example
|
|
31
|
+
#
|
|
32
|
+
# # Gemfile: gem "gemini-ai"
|
|
33
|
+
#
|
|
34
|
+
# class GeminiAdapter < ActionPrompt::Adapters::Base
|
|
35
|
+
# def initialize(api_key:)
|
|
36
|
+
# @client = Gemini.new(
|
|
37
|
+
# credentials: { service: "generative-language-api", api_key: api_key },
|
|
38
|
+
# options: { model: "gemini-pro", server_sent_events: false }
|
|
39
|
+
# )
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# def complete(prompt_text, options = {})
|
|
43
|
+
# result = @client.generate_content(
|
|
44
|
+
# { contents: { role: "user", parts: { text: prompt_text } } },
|
|
45
|
+
# request_options: { model: options[:model] }.compact
|
|
46
|
+
# )
|
|
47
|
+
# result.dig("candidates", 0, "content", "parts", 0, "text")
|
|
48
|
+
# end
|
|
49
|
+
# end
|
|
50
|
+
class Base
|
|
51
|
+
# Sends +prompt_text+ to the LLM and returns its text response.
|
|
52
|
+
#
|
|
53
|
+
# @param prompt_text [String] the fully rendered prompt
|
|
54
|
+
# @param options [Hash] adapter-specific options (e.g. +:model+, +:temperature+)
|
|
55
|
+
# @return [String] the LLM response text
|
|
56
|
+
# @raise [NotImplementedError] if the subclass does not implement this method
|
|
57
|
+
def complete(prompt_text, options = {}) # rubocop:disable Lint/UnusedMethodArgument
|
|
58
|
+
raise NotImplementedError, "#{self.class}#complete must be implemented by subclasses"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|