promptly 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/.rspec +2 -0
- data/.standard.yml +3 -0
- data/LICENSE +21 -0
- data/README.md +447 -0
- data/Rakefile +8 -0
- data/lib/promptly/locator.rb +44 -0
- data/lib/promptly/railtie.rb +15 -0
- data/lib/promptly/renderer.rb +45 -0
- data/lib/promptly/tasks/ai_prompts.rake +25 -0
- data/lib/promptly/version.rb +5 -0
- data/lib/promptly.rb +50 -0
- data/promptly.gemspec +44 -0
- metadata +114 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a2623e7a763b98955af565cb861e43980b214fb22d41dd88ab0b62f5055d0cb5
|
4
|
+
data.tar.gz: ccbfe0e62c1f64a64fee853adcdd65696d94b54405ef11a3785df8e30ab70657
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 816567532c493a580a3271722b0dba4ad950dca6096a8213dfe6312e032e4d86efd8119eff1cc8a96a0b715eddfcafe2b5d346cb171f726f95a1c76e1df5dfdd
|
7
|
+
data.tar.gz: 0abd4d2331934ff0d9e0684a11eac7f5b15458f18eed11a56c91e364ca6cad08d530b3a233d8673562888a504608896197f98dd546ca5baa624a2fe84c7f339e
|
data/.rspec
ADDED
data/.standard.yml
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025
|
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,447 @@
|
|
1
|
+
# Promptly
|
2
|
+
|
3
|
+
Opinionated Rails integration for reusable AI prompt templates. Build maintainable, localized, and testable AI prompts using ERB or Liquid templates with Rails conventions.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- **Template rendering**: ERB (via ActionView) and optional Liquid support
|
8
|
+
- **I18n integration**: Automatic locale fallback (`welcome.es.erb` → `welcome.en.erb` → `welcome.erb`)
|
9
|
+
- **Rails conventions**: Store prompts in `app/prompts/` with organized subdirectories
|
10
|
+
- **Preview & CLI**: Test prompts in Rails console or via rake tasks
|
11
|
+
- **Minimal setup**: Auto-loads via Railtie, zero configuration required
|
12
|
+
|
13
|
+
## Install
|
14
|
+
|
15
|
+
Add to your Gemfile:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem "promptly"
|
19
|
+
```
|
20
|
+
|
21
|
+
For Liquid template support, also add:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
gem "liquid"
|
25
|
+
```
|
26
|
+
|
27
|
+
Then run:
|
28
|
+
|
29
|
+
```bash
|
30
|
+
bundle install
|
31
|
+
```
|
32
|
+
|
33
|
+
## Quick Start
|
34
|
+
|
35
|
+
### 1. Create prompt templates
|
36
|
+
|
37
|
+
Create `app/prompts/user_onboarding/welcome_email.en.erb`:
|
38
|
+
|
39
|
+
```erb
|
40
|
+
You are a friendly customer success manager writing a personalized welcome email.
|
41
|
+
|
42
|
+
Context:
|
43
|
+
- User name: <%= name %>
|
44
|
+
- App name: <%= app_name %>
|
45
|
+
- User's role: <%= user_role %>
|
46
|
+
- Available features for this user: <%= features.join(", ") %>
|
47
|
+
- User signed up <%= days_since_signup %> days ago
|
48
|
+
|
49
|
+
Task: Write a warm, personalized welcome email that:
|
50
|
+
1. Addresses the user by name
|
51
|
+
2. Explains the key benefits specific to their role
|
52
|
+
3. Highlights 2-3 most relevant features they should try first
|
53
|
+
4. Includes a clear call-to-action to get started
|
54
|
+
5. Maintains a professional but friendly tone
|
55
|
+
|
56
|
+
Keep the email concise (under 200 words) and actionable.
|
57
|
+
```
|
58
|
+
|
59
|
+
Create `app/prompts/user_onboarding/welcome_email.es.erb`:
|
60
|
+
|
61
|
+
```erb
|
62
|
+
Eres un gerente de éxito del cliente amigable escribiendo un email de bienvenida personalizado.
|
63
|
+
|
64
|
+
Contexto:
|
65
|
+
- Nombre del usuario: <%= name %>
|
66
|
+
- Nombre de la app: <%= app_name %>
|
67
|
+
- Rol del usuario: <%= user_role %>
|
68
|
+
- Funciones disponibles para este usuario: <%= features.join(", ") %>
|
69
|
+
- El usuario se registró hace <%= days_since_signup %> días
|
70
|
+
|
71
|
+
Tarea: Escribe un email de bienvenida cálido y personalizado que:
|
72
|
+
1. Se dirija al usuario por su nombre
|
73
|
+
2. Explique los beneficios clave específicos para su rol
|
74
|
+
3. Destaque 2-3 funciones más relevantes que debería probar primero
|
75
|
+
4. Incluya una llamada a la acción clara para comenzar
|
76
|
+
5. Mantenga un tono profesional pero amigable
|
77
|
+
|
78
|
+
Mantén el email conciso (menos de 200 palabras) y orientado a la acción.
|
79
|
+
```
|
80
|
+
|
81
|
+
### 2. Render in your Rails app
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
# In a controller, service, or anywhere in Rails
|
85
|
+
prompt = Promptly.preview(
|
86
|
+
"user_onboarding/welcome_email",
|
87
|
+
locale: :es,
|
88
|
+
locals: {
|
89
|
+
name: "María García",
|
90
|
+
app_name: "ProjectHub",
|
91
|
+
user_role: "Team Lead",
|
92
|
+
features: ["Create projects", "Invite team members", "Track progress", "Generate reports"],
|
93
|
+
days_since_signup: 2
|
94
|
+
}
|
95
|
+
)
|
96
|
+
|
97
|
+
# Send to your AI service (OpenAI, Anthropic, etc.)
|
98
|
+
ai_response = openai_client.completions(
|
99
|
+
model: "gpt-4",
|
100
|
+
messages: [{role: "user", content: prompt}]
|
101
|
+
)
|
102
|
+
|
103
|
+
puts ai_response.dig("choices", 0, "message", "content")
|
104
|
+
# => AI-generated personalized welcome email in Spanish
|
105
|
+
```
|
106
|
+
|
107
|
+
### 3. Test via Rails console
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
rails console
|
111
|
+
|
112
|
+
# Preview the prompt before sending to AI
|
113
|
+
prompt = Promptly.preview(
|
114
|
+
"user_onboarding/welcome_email",
|
115
|
+
locale: :en,
|
116
|
+
locals: {
|
117
|
+
name: "John Smith",
|
118
|
+
app_name: "ProjectHub",
|
119
|
+
user_role: "Developer",
|
120
|
+
features: ["API access", "Code reviews", "Deployment tools"],
|
121
|
+
days_since_signup: 1
|
122
|
+
}
|
123
|
+
)
|
124
|
+
puts prompt
|
125
|
+
|
126
|
+
# Uses I18n.locale by default
|
127
|
+
I18n.locale = :es
|
128
|
+
prompt = Promptly.preview(
|
129
|
+
"user_onboarding/welcome_email",
|
130
|
+
locals: {
|
131
|
+
name: "María García",
|
132
|
+
app_name: "ProjectHub",
|
133
|
+
user_role: "Team Lead",
|
134
|
+
features: ["Crear proyectos", "Invitar miembros", "Seguimiento"],
|
135
|
+
days_since_signup: 3
|
136
|
+
}
|
137
|
+
)
|
138
|
+
```
|
139
|
+
|
140
|
+
### 4. CLI preview
|
141
|
+
|
142
|
+
```bash
|
143
|
+
# Preview specific locale (shows the prompt, not AI output)
|
144
|
+
rails ai_prompts:preview[user_onboarding/welcome_email,es]
|
145
|
+
|
146
|
+
# Uses default locale
|
147
|
+
rails ai_prompts:preview[user_onboarding/welcome_email]
|
148
|
+
```
|
149
|
+
|
150
|
+
## Rails App Integration
|
151
|
+
|
152
|
+
### Service Object Pattern
|
153
|
+
|
154
|
+
```ruby
|
155
|
+
# app/services/ai_prompt_service.rb
|
156
|
+
class AiPromptService
|
157
|
+
def self.generate_welcome_email(user, locale: I18n.locale)
|
158
|
+
prompt = Promptly.preview(
|
159
|
+
"user_onboarding/welcome_email",
|
160
|
+
locale: locale,
|
161
|
+
locals: {
|
162
|
+
name: user.full_name,
|
163
|
+
app_name: Rails.application.class.module_parent_name,
|
164
|
+
user_role: user.role.humanize,
|
165
|
+
features: available_features_for(user),
|
166
|
+
days_since_signup: (Date.current - user.created_at.to_date).to_i
|
167
|
+
}
|
168
|
+
)
|
169
|
+
|
170
|
+
# Send to AI service and return generated content
|
171
|
+
openai_client.chat(
|
172
|
+
model: "gpt-4",
|
173
|
+
messages: [{role: "user", content: prompt}]
|
174
|
+
).dig("choices", 0, "message", "content")
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def self.available_features_for(user)
|
180
|
+
# Return features based on user's plan, role, etc.
|
181
|
+
case user.plan
|
182
|
+
when "basic"
|
183
|
+
["Create projects", "Basic reporting"]
|
184
|
+
when "pro"
|
185
|
+
["Create projects", "Team collaboration", "Advanced analytics", "API access"]
|
186
|
+
else
|
187
|
+
["Create projects"]
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
def self.openai_client
|
192
|
+
@openai_client ||= OpenAI::Client.new(access_token: Rails.application.credentials.openai_api_key)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
```
|
196
|
+
|
197
|
+
### Mailer Integration
|
198
|
+
|
199
|
+
```ruby
|
200
|
+
# app/mailers/user_mailer.rb
|
201
|
+
class UserMailer < ApplicationMailer
|
202
|
+
def welcome_email(user)
|
203
|
+
@user = user
|
204
|
+
@ai_content = AiPromptService.generate_welcome_email(user, locale: user.locale)
|
205
|
+
|
206
|
+
mail(
|
207
|
+
to: user.email,
|
208
|
+
subject: t('mailer.welcome.subject')
|
209
|
+
)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
```
|
213
|
+
|
214
|
+
### Background Job Usage
|
215
|
+
|
216
|
+
```ruby
|
217
|
+
# app/jobs/generate_ai_content_job.rb
|
218
|
+
class GenerateAiContentJob < ApplicationJob
|
219
|
+
def perform(user_id, prompt_identifier, locals = {})
|
220
|
+
user = User.find(user_id)
|
221
|
+
|
222
|
+
prompt = Promptly.preview(
|
223
|
+
prompt_identifier,
|
224
|
+
locale: user.locale,
|
225
|
+
locals: locals.merge(
|
226
|
+
user_name: user.full_name,
|
227
|
+
user_role: user.role,
|
228
|
+
account_type: user.account_type
|
229
|
+
)
|
230
|
+
)
|
231
|
+
|
232
|
+
# Generate AI content
|
233
|
+
ai_response = openai_client.chat(
|
234
|
+
model: "gpt-4",
|
235
|
+
messages: [{role: "user", content: prompt}]
|
236
|
+
)
|
237
|
+
|
238
|
+
generated_content = ai_response.dig("choices", 0, "message", "content")
|
239
|
+
|
240
|
+
# Store or send the generated content
|
241
|
+
user.notifications.create!(
|
242
|
+
title: "AI Generated Content Ready",
|
243
|
+
content: generated_content,
|
244
|
+
notification_type: prompt_identifier.split('/').last
|
245
|
+
)
|
246
|
+
end
|
247
|
+
|
248
|
+
private
|
249
|
+
|
250
|
+
def openai_client
|
251
|
+
@openai_client ||= OpenAI::Client.new(access_token: Rails.application.credentials.openai_api_key)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# Usage
|
256
|
+
GenerateAiContentJob.perform_later(
|
257
|
+
user.id,
|
258
|
+
"coaching/goal_review",
|
259
|
+
{
|
260
|
+
current_goals: user.goals.active.pluck(:title),
|
261
|
+
progress_summary: "Made good progress on fitness goals",
|
262
|
+
challenges: ["Time management", "Consistency"]
|
263
|
+
}
|
264
|
+
)
|
265
|
+
```
|
266
|
+
|
267
|
+
## I18n Prompts Usage
|
268
|
+
|
269
|
+
### Directory Structure
|
270
|
+
|
271
|
+
```
|
272
|
+
app/prompts/
|
273
|
+
├── user_onboarding/
|
274
|
+
│ ├── welcome_email.en.erb # English AI prompt
|
275
|
+
│ ├── welcome_email.es.erb # Spanish AI prompt
|
276
|
+
│ └── onboarding_checklist.erb # Fallback (any locale)
|
277
|
+
├── content_generation/
|
278
|
+
│ ├── blog_post_outline.en.erb
|
279
|
+
│ ├── social_media_post.es.erb
|
280
|
+
│ └── product_description.erb
|
281
|
+
└── ai_coaching/
|
282
|
+
├── goal_review.en.liquid # Liquid AI prompt
|
283
|
+
└── goal_review.es.liquid
|
284
|
+
```
|
285
|
+
|
286
|
+
### Locale Resolution
|
287
|
+
|
288
|
+
Promptly follows this resolution order:
|
289
|
+
|
290
|
+
1. **Requested locale**: `welcome.es.erb` (if `locale: :es` specified)
|
291
|
+
2. **Default locale**: `welcome.en.erb` (if `I18n.default_locale == :en`)
|
292
|
+
3. **Fallback**: `welcome.erb` (no locale suffix)
|
293
|
+
|
294
|
+
```ruby
|
295
|
+
# Configure I18n in your Rails app
|
296
|
+
# config/application.rb
|
297
|
+
config.i18n.default_locale = :en
|
298
|
+
config.i18n.available_locales = [:en, :es, :fr]
|
299
|
+
|
300
|
+
# Usage examples
|
301
|
+
I18n.locale = :es
|
302
|
+
I18n.default_locale = :en
|
303
|
+
|
304
|
+
# Will try: welcome_email.es.erb → welcome_email.en.erb → welcome_email.erb
|
305
|
+
prompt = Promptly.preview(
|
306
|
+
"user_onboarding/welcome_email",
|
307
|
+
locals: {
|
308
|
+
name: "María García",
|
309
|
+
app_name: "ProjectHub",
|
310
|
+
user_role: "Manager",
|
311
|
+
features: ["Team management", "Analytics", "Reporting"],
|
312
|
+
days_since_signup: 1
|
313
|
+
}
|
314
|
+
)
|
315
|
+
|
316
|
+
# Force specific locale for AI prompt generation
|
317
|
+
prompt = Promptly.preview(
|
318
|
+
"content_generation/blog_post_outline",
|
319
|
+
locale: :fr,
|
320
|
+
locals: {
|
321
|
+
topic: "Intelligence Artificielle",
|
322
|
+
target_audience: "Développeurs",
|
323
|
+
word_count: 1500
|
324
|
+
}
|
325
|
+
)
|
326
|
+
```
|
327
|
+
|
328
|
+
### Liquid Templates
|
329
|
+
|
330
|
+
For more complex templating needs, use Liquid:
|
331
|
+
|
332
|
+
```liquid
|
333
|
+
<!-- app/prompts/ai_coaching/goal_review.en.liquid -->
|
334
|
+
You are an experienced life coach conducting a goal review session.
|
335
|
+
|
336
|
+
Context:
|
337
|
+
- Client name: {{ user_name }}
|
338
|
+
- Goals being reviewed: {% for goal in current_goals %}{{ goal }}{% unless forloop.last %}, {% endunless %}{% endfor %}
|
339
|
+
- Recent progress: {{ progress_summary }}
|
340
|
+
- Current challenges: {% for challenge in challenges %}{{ challenge }}{% unless forloop.last %}, {% endunless %}{% endfor %}
|
341
|
+
- Review period: {{ review_period | default: "monthly" }}
|
342
|
+
|
343
|
+
Task: Provide a personalized goal review that:
|
344
|
+
1. Acknowledges their progress and celebrates wins
|
345
|
+
2. Addresses each challenge with specific, actionable advice
|
346
|
+
3. Suggests 2-3 concrete next steps for the coming {{ review_period }}
|
347
|
+
4. Asks 1-2 thoughtful questions to help them reflect
|
348
|
+
5. Maintains an encouraging but realistic tone
|
349
|
+
|
350
|
+
{% if current_goals.size > 5 %}
|
351
|
+
Note: The client has many goals. Help them prioritize the most important ones.
|
352
|
+
{% endif %}
|
353
|
+
|
354
|
+
Format your response as a conversational coaching session, not a formal report.
|
355
|
+
```
|
356
|
+
|
357
|
+
```ruby
|
358
|
+
# Generate AI coaching content with Liquid template
|
359
|
+
prompt = Promptly.preview(
|
360
|
+
"ai_coaching/goal_review",
|
361
|
+
locale: :en,
|
362
|
+
locals: {
|
363
|
+
user_name: "Alex",
|
364
|
+
current_goals: ["Run 5K under 25min", "Gym 3x/week", "Read 12 books/year"],
|
365
|
+
progress_summary: "Consistent with gym, behind on running pace, ahead on reading",
|
366
|
+
challenges: ["Time management", "Motivation on rainy days"],
|
367
|
+
review_period: "monthly"
|
368
|
+
}
|
369
|
+
)
|
370
|
+
|
371
|
+
# Send to AI service for personalized coaching
|
372
|
+
ai_coaching_session = openai_client.chat(
|
373
|
+
model: "gpt-4",
|
374
|
+
messages: [{role: "user", content: prompt}]
|
375
|
+
).dig("choices", 0, "message", "content")
|
376
|
+
```
|
377
|
+
|
378
|
+
## Configuration
|
379
|
+
|
380
|
+
### Custom Prompts Path
|
381
|
+
|
382
|
+
```ruby
|
383
|
+
# config/initializers/rails_ai_prompts.rb
|
384
|
+
Promptly.prompts_path = Rails.root.join("lib", "ai_prompts")
|
385
|
+
```
|
386
|
+
|
387
|
+
### Direct Template Rendering
|
388
|
+
|
389
|
+
```ruby
|
390
|
+
# Render ERB directly (without file lookup)
|
391
|
+
template = "Hello <%= name %>, welcome to <%= app %>!"
|
392
|
+
output = Promptly.render(template, locals: {name: "John", app: "MyApp"})
|
393
|
+
|
394
|
+
# Render Liquid directly
|
395
|
+
template = "Hello {{ name }}, welcome to {{ app }}!"
|
396
|
+
output = Promptly.render(template, locals: {name: "John", app: "MyApp"}, engine: :liquid)
|
397
|
+
```
|
398
|
+
|
399
|
+
## API Reference
|
400
|
+
|
401
|
+
### `Promptly.preview(identifier, locale: nil, locals: {})`
|
402
|
+
|
403
|
+
Renders a template by identifier with locale fallback.
|
404
|
+
|
405
|
+
- **identifier**: Template path like `"user_onboarding/welcome"`
|
406
|
+
- **locale**: Specific locale (defaults to `I18n.locale`)
|
407
|
+
- **locals**: Hash of variables for template
|
408
|
+
|
409
|
+
### `Promptly.render(template, locals: {}, engine: :erb)`
|
410
|
+
|
411
|
+
Renders template string directly.
|
412
|
+
|
413
|
+
- **template**: Template string
|
414
|
+
- **locals**: Hash of variables
|
415
|
+
- **engine**: `:erb` or `:liquid`
|
416
|
+
|
417
|
+
### `Promptly.prompts_path`
|
418
|
+
|
419
|
+
Get/set the root directory for prompt templates (defaults to `Rails.root/app/prompts`).
|
420
|
+
|
421
|
+
## Development
|
422
|
+
|
423
|
+
```bash
|
424
|
+
# Install dependencies
|
425
|
+
bundle install
|
426
|
+
|
427
|
+
# Run tests
|
428
|
+
bundle exec rspec
|
429
|
+
|
430
|
+
# Run linter
|
431
|
+
bundle exec standardrb
|
432
|
+
|
433
|
+
# Build gem
|
434
|
+
rake build
|
435
|
+
```
|
436
|
+
|
437
|
+
## Contributing
|
438
|
+
|
439
|
+
1. Fork it
|
440
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
441
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
442
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
443
|
+
5. Create new Pull Request
|
444
|
+
|
445
|
+
## License
|
446
|
+
|
447
|
+
MIT
|
data/Rakefile
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Promptly
|
4
|
+
class Locator
|
5
|
+
SUPPORTED_EXTS = [".erb", ".liquid"].freeze
|
6
|
+
|
7
|
+
def self.prompts_path
|
8
|
+
Promptly.prompts_path
|
9
|
+
end
|
10
|
+
|
11
|
+
# identifier: e.g. "user_onboarding/welcome"
|
12
|
+
# locale: e.g. :en, :es
|
13
|
+
# returns absolute path to template file or nil
|
14
|
+
def self.resolve(identifier, locale: nil)
|
15
|
+
base = File.join(prompts_path, identifier)
|
16
|
+
requested_locale = (locale || (defined?(I18n) ? I18n.locale : nil))&.to_s
|
17
|
+
default_locale = (defined?(I18n) ? I18n.default_locale : nil)&.to_s
|
18
|
+
|
19
|
+
candidates = []
|
20
|
+
[requested_locale, default_locale, nil].compact.uniq.each do |loc|
|
21
|
+
if loc
|
22
|
+
SUPPORTED_EXTS.each do |ext|
|
23
|
+
candidates << "#{base}.#{loc}#{ext}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
SUPPORTED_EXTS.each do |ext|
|
28
|
+
candidates << "#{base}#{ext}"
|
29
|
+
end
|
30
|
+
|
31
|
+
candidates.find { |p| File.file?(p) }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Choose engine based on file extension
|
35
|
+
def self.engine_for(path)
|
36
|
+
case File.extname(path)
|
37
|
+
when ".erb" then :erb
|
38
|
+
when ".liquid" then :liquid
|
39
|
+
else
|
40
|
+
:erb
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Promptly
|
4
|
+
class Railtie < Rails::Railtie
|
5
|
+
initializer "promptly.configure" do
|
6
|
+
# Intentionally minimal per DHH style; conventions over configuration
|
7
|
+
# Hook points will be added as features land.
|
8
|
+
Rails.logger.info("[promptly] loaded") if defined?(Rails.logger)
|
9
|
+
end
|
10
|
+
|
11
|
+
rake_tasks do
|
12
|
+
load "promptly/tasks/ai_prompts.rake"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "action_view"
|
4
|
+
|
5
|
+
module Promptly
|
6
|
+
class Renderer
|
7
|
+
def self.render(template, locals: {}, engine: :erb)
|
8
|
+
case engine.to_sym
|
9
|
+
when :erb
|
10
|
+
render_erb(template, locals)
|
11
|
+
when :liquid
|
12
|
+
render_liquid(template, locals)
|
13
|
+
else
|
14
|
+
raise ArgumentError, "Unsupported engine: #{engine} (use :erb or :liquid)"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.render_erb(template, locals)
|
19
|
+
view_class = if ActionView::Base.respond_to?(:with_empty_template_cache)
|
20
|
+
ActionView::Base.with_empty_template_cache
|
21
|
+
else
|
22
|
+
Class.new(ActionView::Base)
|
23
|
+
end
|
24
|
+
|
25
|
+
lookup = ActionView::LookupContext.new(ActionView::PathSet.new([]))
|
26
|
+
av = view_class.new(lookup, {}, nil)
|
27
|
+
|
28
|
+
av.render(inline: template, type: :erb, locals: locals || {})
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.render_liquid(template, locals)
|
32
|
+
unless defined?(::Liquid)
|
33
|
+
raise LoadError, "Liquid is not available. Add `gem 'liquid'` to your Gemfile to use :liquid engine."
|
34
|
+
end
|
35
|
+
|
36
|
+
stringified = (locals || {}).each_with_object({}) do |(k, v), h|
|
37
|
+
h[k.to_s] = v
|
38
|
+
end
|
39
|
+
|
40
|
+
::Liquid::Template.parse(template).render(stringified)
|
41
|
+
end
|
42
|
+
|
43
|
+
private_class_method :render_erb, :render_liquid
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
namespace :ai_prompts do
|
4
|
+
desc "Preview a prompt: rake ai_prompts:preview[identifier,locale]"
|
5
|
+
task :preview, [:identifier, :locale] => :environment do |_, args|
|
6
|
+
identifier = args[:identifier]
|
7
|
+
locale = args[:locale]
|
8
|
+
prompts_path = ENV["PROMPTS_PATH"]
|
9
|
+
|
10
|
+
unless identifier
|
11
|
+
warn "Usage: rake ai_prompts:preview[identifier,locale]"
|
12
|
+
exit 1
|
13
|
+
end
|
14
|
+
|
15
|
+
begin
|
16
|
+
Promptly.prompts_path = prompts_path if prompts_path
|
17
|
+
|
18
|
+
output = Promptly.preview(identifier, locale: locale)
|
19
|
+
puts output
|
20
|
+
rescue Promptly::Error => e
|
21
|
+
warn "Error: #{e.class}: #{e.message}"
|
22
|
+
exit 1
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/promptly.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "promptly/version"
|
4
|
+
require_relative "promptly/renderer"
|
5
|
+
require_relative "promptly/locator"
|
6
|
+
|
7
|
+
module Promptly
|
8
|
+
class Error < StandardError; end
|
9
|
+
|
10
|
+
def self.render(template, locals: {}, engine: :erb)
|
11
|
+
Renderer.render(template, locals: locals, engine: engine)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Configurable prompts root (defaults to Rails.root/app/prompts when Rails is present)
|
15
|
+
def self.prompts_path
|
16
|
+
@prompts_path || default_prompts_path
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.prompts_path=(path)
|
20
|
+
@prompts_path = path
|
21
|
+
end
|
22
|
+
|
23
|
+
# Preview a template by identifier using locator rules
|
24
|
+
# identifier: "user_onboarding/welcome"
|
25
|
+
# locale: defaults to I18n.locale when available
|
26
|
+
def self.preview(identifier, locale: nil, locals: {})
|
27
|
+
path = Locator.resolve(identifier, locale: locale)
|
28
|
+
raise Error, "Template not found for '#{identifier}' (locale: #{locale.inspect}) under #{prompts_path}" unless path
|
29
|
+
|
30
|
+
engine = Locator.engine_for(path)
|
31
|
+
template = File.read(path)
|
32
|
+
Renderer.render(template, locals: locals, engine: engine)
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.default_prompts_path
|
36
|
+
if defined?(::Rails) && Rails.respond_to?(:root) && Rails.root
|
37
|
+
File.join(Rails.root.to_s, "app", "prompts")
|
38
|
+
else
|
39
|
+
File.expand_path("app/prompts", Dir.pwd)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Auto-load Railtie when inside Rails
|
45
|
+
begin
|
46
|
+
require "rails/railtie"
|
47
|
+
require_relative "promptly/railtie"
|
48
|
+
rescue LoadError
|
49
|
+
# Rails not available; noop
|
50
|
+
end
|
data/promptly.gemspec
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/promptly/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "promptly"
|
7
|
+
spec.version = Promptly::VERSION
|
8
|
+
spec.authors = ["Wilbur Suero"]
|
9
|
+
spec.email = ["wilbur@example.com"]
|
10
|
+
|
11
|
+
spec.summary = "Opinionated Rails integration for reusable AI prompt templates"
|
12
|
+
spec.description = "Build maintainable, localized, and testable AI prompts using ERB or Liquid templates with Rails conventions"
|
13
|
+
spec.homepage = "https://github.com/wilbursuero/promptly"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 3.0.0"
|
16
|
+
|
17
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
18
|
+
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
20
|
+
spec.metadata["source_code_uri"] = "https://github.com/wilbursuero/promptly"
|
21
|
+
spec.metadata["changelog_uri"] = "https://github.com/wilbursuero/promptly/blob/main/CHANGELOG.md"
|
22
|
+
spec.metadata["documentation_uri"] = "https://github.com/wilbursuero/promptly/blob/main/README.md"
|
23
|
+
|
24
|
+
# Specify which files should be added to the gem when it is released.
|
25
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
26
|
+
gemfiles = Dir.chdir(__dir__) do
|
27
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
28
|
+
(File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
spec.files = gemfiles
|
32
|
+
|
33
|
+
spec.bindir = "exe"
|
34
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
35
|
+
spec.require_paths = ["lib"]
|
36
|
+
|
37
|
+
# Runtime dependencies
|
38
|
+
spec.add_dependency "actionview", "~> 7.0"
|
39
|
+
|
40
|
+
# Development dependencies
|
41
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
42
|
+
spec.add_development_dependency "standard", "~> 1.37"
|
43
|
+
spec.add_development_dependency "liquid", "~> 5.5"
|
44
|
+
end
|
metadata
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: promptly
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Wilbur Suero
|
8
|
+
bindir: exe
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-08-16 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: actionview
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: '7.0'
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: '7.0'
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: rspec
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '3.12'
|
33
|
+
type: :development
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '3.12'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: standard
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '1.37'
|
47
|
+
type: :development
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '1.37'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: liquid
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '5.5'
|
61
|
+
type: :development
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '5.5'
|
68
|
+
description: Build maintainable, localized, and testable AI prompts using ERB or Liquid
|
69
|
+
templates with Rails conventions
|
70
|
+
email:
|
71
|
+
- wilbur@example.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".rspec"
|
77
|
+
- ".standard.yml"
|
78
|
+
- LICENSE
|
79
|
+
- README.md
|
80
|
+
- Rakefile
|
81
|
+
- lib/promptly.rb
|
82
|
+
- lib/promptly/locator.rb
|
83
|
+
- lib/promptly/railtie.rb
|
84
|
+
- lib/promptly/renderer.rb
|
85
|
+
- lib/promptly/tasks/ai_prompts.rake
|
86
|
+
- lib/promptly/version.rb
|
87
|
+
- promptly.gemspec
|
88
|
+
homepage: https://github.com/wilbursuero/promptly
|
89
|
+
licenses:
|
90
|
+
- MIT
|
91
|
+
metadata:
|
92
|
+
allowed_push_host: https://rubygems.org
|
93
|
+
homepage_uri: https://github.com/wilbursuero/promptly
|
94
|
+
source_code_uri: https://github.com/wilbursuero/promptly
|
95
|
+
changelog_uri: https://github.com/wilbursuero/promptly/blob/main/CHANGELOG.md
|
96
|
+
documentation_uri: https://github.com/wilbursuero/promptly/blob/main/README.md
|
97
|
+
rdoc_options: []
|
98
|
+
require_paths:
|
99
|
+
- lib
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: 3.0.0
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
requirements: []
|
111
|
+
rubygems_version: 3.6.2
|
112
|
+
specification_version: 4
|
113
|
+
summary: Opinionated Rails integration for reusable AI prompt templates
|
114
|
+
test_files: []
|