promptly 0.1.2 → 0.1.13
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 +4 -4
- data/.ruby-version +1 -0
- data/README.md +164 -19
- data/lib/generators/promptly/prompt_generator.rb +117 -0
- data/lib/promptly/cache.rb +67 -0
- data/lib/promptly/helper.rb +19 -0
- data/lib/promptly/railtie.rb +20 -0
- data/lib/promptly/renderer.rb +6 -0
- data/lib/promptly/tasks/ai_prompts.rake +122 -4
- data/lib/promptly/version.rb +1 -1
- data/lib/promptly.rb +22 -3
- metadata +26 -6
- data/promptly.gemspec +0 -44
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: acd140832db9c4f719f094548791c956ec3cb0f7380cf1c213e9923be1070fe2
|
4
|
+
data.tar.gz: abdba3ecdc1c1d4a03f012944c5ebc08d906c02266872eb9e3fb54483add5f55
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b714d8ec43e3491248fc5a229cbbc2018227a28b32b68d84e8961fab5fe5185b860c55de425f3c573eb9b776f1922b5e56b331a6cf2be95cf7d8f33b303f8d1a
|
7
|
+
data.tar.gz: 69afcef9ff51ba00326a5e1efa1226777aecc97cfc9f9d85998a960353758a05b1fa967cef716dd441618f68afe67194333c0f4c1253a1b773d3f2818930dd12
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
3.3.4
|
data/README.md
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
|
1
2
|
# Promptly
|
2
3
|
|
3
4
|
Opinionated Rails integration for reusable AI prompt templates. Build maintainable, localized, and testable AI prompts using ERB or Liquid templates with Rails conventions.
|
@@ -7,8 +8,9 @@ Opinionated Rails integration for reusable AI prompt templates. Build maintainab
|
|
7
8
|
- **Template rendering**: ERB (via ActionView) and optional Liquid support
|
8
9
|
- **I18n integration**: Automatic locale fallback (`welcome.es.erb` → `welcome.en.erb` → `welcome.erb`)
|
9
10
|
- **Rails conventions**: Store prompts in `app/prompts/` with organized subdirectories
|
10
|
-
- **
|
11
|
+
- **Render & CLI**: Test prompts in Rails console or via rake tasks
|
11
12
|
- **Minimal setup**: Auto-loads via Railtie, zero configuration required
|
13
|
+
- **Prompt caching**: Configurable cache store, TTL, and cache-bypass options
|
12
14
|
|
13
15
|
## Install
|
14
16
|
|
@@ -82,7 +84,7 @@ Mantén el email conciso (menos de 200 palabras) y orientado a la acción.
|
|
82
84
|
|
83
85
|
```ruby
|
84
86
|
# In a controller, service, or anywhere in Rails
|
85
|
-
prompt = Promptly.
|
87
|
+
prompt = Promptly.render(
|
86
88
|
"user_onboarding/welcome_email",
|
87
89
|
locale: :es,
|
88
90
|
locals: {
|
@@ -109,8 +111,8 @@ puts ai_response.dig("choices", 0, "message", "content")
|
|
109
111
|
```ruby
|
110
112
|
rails console
|
111
113
|
|
112
|
-
#
|
113
|
-
prompt = Promptly.
|
114
|
+
# Render the prompt before sending to AI
|
115
|
+
prompt = Promptly.render(
|
114
116
|
"user_onboarding/welcome_email",
|
115
117
|
locale: :en,
|
116
118
|
locals: {
|
@@ -125,7 +127,7 @@ puts prompt
|
|
125
127
|
|
126
128
|
# Uses I18n.locale by default
|
127
129
|
I18n.locale = :es
|
128
|
-
prompt = Promptly.
|
130
|
+
prompt = Promptly.render(
|
129
131
|
"user_onboarding/welcome_email",
|
130
132
|
locals: {
|
131
133
|
name: "María García",
|
@@ -137,16 +139,42 @@ prompt = Promptly.preview(
|
|
137
139
|
)
|
138
140
|
```
|
139
141
|
|
140
|
-
### 4. CLI
|
142
|
+
### 4. CLI rendering
|
141
143
|
|
142
144
|
```bash
|
143
|
-
#
|
144
|
-
rails ai_prompts:
|
145
|
+
# Render specific locale (shows the prompt, not AI output)
|
146
|
+
rails ai_prompts:render[user_onboarding/welcome_email,es]
|
145
147
|
|
146
148
|
# Uses default locale
|
147
|
-
rails ai_prompts:
|
149
|
+
rails ai_prompts:render[user_onboarding/welcome_email]
|
150
|
+
```
|
151
|
+
|
152
|
+
## Helper: render_prompt
|
153
|
+
|
154
|
+
Use a concise helper anywhere in Rails to render prompts with locals that are also exposed as instance variables inside ERB templates.
|
155
|
+
|
156
|
+
* **Auto-included**: Controllers, Mailers, and Jobs via Railtie.
|
157
|
+
* **Services/Plain Ruby**: `include Promptly::Helper`.
|
158
|
+
|
159
|
+
Example template and usage:
|
160
|
+
|
161
|
+
```erb
|
162
|
+
# app/prompts/welcome_email.erb
|
163
|
+
Hello <%= @user.name %>, welcome to our service!
|
164
|
+
We're excited to have you join.
|
148
165
|
```
|
149
166
|
|
167
|
+
```ruby
|
168
|
+
# In a mailer, job, controller, or a service that includes Promptly::Helper
|
169
|
+
rendered = render_prompt("welcome_email", user: @user)
|
170
|
+
```
|
171
|
+
|
172
|
+
Notes:
|
173
|
+
|
174
|
+
- **Locals become @instance variables** in ERB. Passing `user: @user` makes `@user` available in the template.
|
175
|
+
- **Localization**: `render_prompt("welcome_email", locale: :es, user: user)` resolves `welcome_email.es.erb` with fallback per `Promptly::Locator`.
|
176
|
+
- **Caching**: Controlled per call (`cache:`, `ttl:`) and globally via `Promptly::Cache`.
|
177
|
+
|
150
178
|
## Rails App Integration
|
151
179
|
|
152
180
|
### Service Object Pattern
|
@@ -155,7 +183,7 @@ rails ai_prompts:preview[user_onboarding/welcome_email]
|
|
155
183
|
# app/services/ai_prompt_service.rb
|
156
184
|
class AiPromptService
|
157
185
|
def self.generate_welcome_email(user, locale: I18n.locale)
|
158
|
-
prompt = Promptly.
|
186
|
+
prompt = Promptly.render(
|
159
187
|
"user_onboarding/welcome_email",
|
160
188
|
locale: locale,
|
161
189
|
locals: {
|
@@ -219,7 +247,7 @@ class GenerateAiContentJob < ApplicationJob
|
|
219
247
|
def perform(user_id, prompt_identifier, locals = {})
|
220
248
|
user = User.find(user_id)
|
221
249
|
|
222
|
-
prompt = Promptly.
|
250
|
+
prompt = Promptly.render(
|
223
251
|
prompt_identifier,
|
224
252
|
locale: user.locale,
|
225
253
|
locals: locals.merge(
|
@@ -302,7 +330,7 @@ I18n.locale = :es
|
|
302
330
|
I18n.default_locale = :en
|
303
331
|
|
304
332
|
# Will try: welcome_email.es.erb → welcome_email.en.erb → welcome_email.erb
|
305
|
-
prompt = Promptly.
|
333
|
+
prompt = Promptly.render(
|
306
334
|
"user_onboarding/welcome_email",
|
307
335
|
locals: {
|
308
336
|
name: "María García",
|
@@ -314,7 +342,7 @@ prompt = Promptly.preview(
|
|
314
342
|
)
|
315
343
|
|
316
344
|
# Force specific locale for AI prompt generation
|
317
|
-
prompt = Promptly.
|
345
|
+
prompt = Promptly.render(
|
318
346
|
"content_generation/blog_post_outline",
|
319
347
|
locale: :fr,
|
320
348
|
locals: {
|
@@ -356,7 +384,7 @@ Format your response as a conversational coaching session, not a formal report.
|
|
356
384
|
|
357
385
|
```ruby
|
358
386
|
# Generate AI coaching content with Liquid template
|
359
|
-
prompt = Promptly.
|
387
|
+
prompt = Promptly.render(
|
360
388
|
"ai_coaching/goal_review",
|
361
389
|
locale: :en,
|
362
390
|
locals: {
|
@@ -384,29 +412,146 @@ ai_coaching_session = openai_client.chat(
|
|
384
412
|
Promptly.prompts_path = Rails.root.join("lib", "ai_prompts")
|
385
413
|
```
|
386
414
|
|
415
|
+
### Caching
|
416
|
+
|
417
|
+
Promptly supports optional caching for rendered prompts.
|
418
|
+
|
419
|
+
- Default: enabled, TTL = 3600 seconds (1 hour).
|
420
|
+
- In Rails, the Railtie auto-uses `Rails.cache` if present.
|
421
|
+
|
422
|
+
Configure globally:
|
423
|
+
|
424
|
+
```ruby
|
425
|
+
# config/initializers/promptly.rb
|
426
|
+
Promptly::Cache.configure do |c|
|
427
|
+
c.store = Rails.cache # or any ActiveSupport::Cache store
|
428
|
+
c.ttl = 3600 # default TTL in seconds
|
429
|
+
c.enabled = true # globally enable/disable caching
|
430
|
+
end
|
431
|
+
```
|
432
|
+
|
433
|
+
Per-call options:
|
434
|
+
|
435
|
+
```ruby
|
436
|
+
# Bypass cache for this render only
|
437
|
+
Promptly.render("user_onboarding/welcome_email", locals: {...}, cache: false)
|
438
|
+
|
439
|
+
# Custom TTL for this render only
|
440
|
+
Promptly.render("user_onboarding/welcome_email", locals: {...}, ttl: 5.minutes)
|
441
|
+
```
|
442
|
+
|
443
|
+
Invalidation:
|
444
|
+
|
445
|
+
```ruby
|
446
|
+
# Clear entire cache store (if supported by the store)
|
447
|
+
Promptly::Cache.clear
|
448
|
+
|
449
|
+
# Delete a specific cached entry
|
450
|
+
Promptly::Cache.delete(
|
451
|
+
identifier: "user_onboarding/welcome_email",
|
452
|
+
locale: :en,
|
453
|
+
locals: {name: "John"},
|
454
|
+
prompts_path: Promptly.prompts_path
|
455
|
+
)
|
456
|
+
```
|
457
|
+
|
387
458
|
### Direct Template Rendering
|
388
459
|
|
389
460
|
```ruby
|
390
461
|
# Render ERB directly (without file lookup)
|
391
462
|
template = "Hello <%= name %>, welcome to <%= app %>!"
|
392
|
-
output = Promptly.
|
463
|
+
output = Promptly.render_template(template, locals: {name: "John", app: "MyApp"})
|
393
464
|
|
394
465
|
# Render Liquid directly
|
395
466
|
template = "Hello {{ name }}, welcome to {{ app }}!"
|
396
|
-
output = Promptly.
|
467
|
+
output = Promptly.render_template(template, locals: {name: "John", app: "MyApp"}, engine: :liquid)
|
397
468
|
```
|
398
469
|
|
470
|
+
## Generators
|
471
|
+
|
472
|
+
Create prompt templates following conventions.
|
473
|
+
|
474
|
+
```bash
|
475
|
+
# ERB with multiple locales
|
476
|
+
rails g promptly:prompt user_onboarding/welcome_email --locales en es --engine erb
|
477
|
+
|
478
|
+
# Liquid with a single locale
|
479
|
+
rails g promptly:prompt ai_coaching/goal_review --locales en --engine liquid
|
480
|
+
|
481
|
+
# Fallback-only (no locale suffix)
|
482
|
+
rails g promptly:prompt content_generation/outline --no-locale
|
483
|
+
```
|
484
|
+
|
485
|
+
Options:
|
486
|
+
|
487
|
+
- `--engine` erb|liquid (default: erb)
|
488
|
+
- `--locales` space-separated list (default: I18n.available_locales if available, else `en`)
|
489
|
+
- `--no-locale` create only fallback file (e.g., `welcome_email.erb`)
|
490
|
+
- `--force` overwrite existing files
|
491
|
+
|
492
|
+
Generated files are placed under `app/prompts/` and directories are created as needed.
|
493
|
+
|
494
|
+
Examples:
|
495
|
+
|
496
|
+
- `app/prompts/user_onboarding/welcome_email.en.erb`
|
497
|
+
- `app/prompts/user_onboarding/welcome_email.es.erb`
|
498
|
+
- `app/prompts/ai_coaching/goal_review.en.liquid`
|
499
|
+
- `app/prompts/content_generation/outline.erb` (fallback-only)
|
500
|
+
|
501
|
+
The generator seeds a minimal, intention-revealing scaffold you can edit immediately.
|
502
|
+
|
503
|
+
## Linting Templates
|
504
|
+
|
505
|
+
Validate your prompt templates from the CLI.
|
506
|
+
|
507
|
+
```bash
|
508
|
+
# Lint all templates under the prompts path
|
509
|
+
rake ai_prompts:lint
|
510
|
+
|
511
|
+
# Lint a specific identifier (path without locale/ext)
|
512
|
+
rake ai_prompts:lint[user_onboarding/welcome_email]
|
513
|
+
|
514
|
+
# Specify locales to check for coverage
|
515
|
+
LOCALES=en,es rake ai_prompts:lint
|
516
|
+
|
517
|
+
# Require placeholders to exist in templates
|
518
|
+
REQUIRED=name,app_name rake ai_prompts:lint[user_onboarding/welcome_email]
|
519
|
+
|
520
|
+
# Point to a custom prompts directory
|
521
|
+
PROMPTS_PATH=lib/ai_prompts rake ai_prompts:lint
|
522
|
+
```
|
523
|
+
|
524
|
+
What it checks:
|
525
|
+
|
526
|
+
- **Syntax errors**
|
527
|
+
- ERB: compiles with `ERB.new` (no execution)
|
528
|
+
- Liquid: parses with `Liquid::Template.parse` (if `liquid` gem present)
|
529
|
+
- **Missing locale files**
|
530
|
+
- For each identifier, warns when required locales are missing
|
531
|
+
- Locales source: `LOCALES` env or `I18n.available_locales`
|
532
|
+
- **Required placeholders**
|
533
|
+
- Best-effort scan for required keys from `REQUIRED` env
|
534
|
+
- ERB: looks for `<%= ... @key ... %>` or `<%= ... key ... %>` usage
|
535
|
+
- Liquid: looks for `{{ key }}` usage
|
536
|
+
|
537
|
+
Exit codes:
|
538
|
+
|
539
|
+
- `0` when all checks pass
|
540
|
+
- `1` when errors are found (syntax or missing required placeholders)
|
541
|
+
|
399
542
|
## API Reference
|
400
543
|
|
401
|
-
### `Promptly.
|
544
|
+
### `Promptly.render(identifier, locale: nil, locals: {}, cache: true, ttl: nil)`
|
402
545
|
|
403
|
-
Renders a template by identifier with locale fallback.
|
546
|
+
Renders a template by identifier with locale fallback and optional caching.
|
404
547
|
|
405
548
|
- **identifier**: Template path like `"user_onboarding/welcome"`
|
406
549
|
- **locale**: Specific locale (defaults to `I18n.locale`)
|
407
550
|
- **locals**: Hash of variables for template
|
551
|
+
- **cache**: Enable/disable caching for this call (defaults to `true`)
|
552
|
+
- **ttl**: Time-to-live in seconds for cache entry (overrides default TTL)
|
408
553
|
|
409
|
-
### `Promptly.
|
554
|
+
### `Promptly.render_template(template, locals: {}, engine: :erb)`
|
410
555
|
|
411
556
|
Renders template string directly.
|
412
557
|
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Rails generator: promptly:prompt
|
4
|
+
# Usage:
|
5
|
+
# rails g promptly:prompt user_onboarding/welcome_email --locales en es --engine erb
|
6
|
+
# Creates prompt templates under app/prompts/ following conventions.
|
7
|
+
|
8
|
+
require "fileutils"
|
9
|
+
|
10
|
+
begin
|
11
|
+
require "rails/generators"
|
12
|
+
require "rails/generators/named_base"
|
13
|
+
rescue LoadError
|
14
|
+
# Allow gem to load without Rails present
|
15
|
+
end
|
16
|
+
|
17
|
+
module Promptly
|
18
|
+
module Generators
|
19
|
+
class PromptGenerator < (defined?(Rails::Generators::NamedBase) ? Rails::Generators::NamedBase : Object)
|
20
|
+
if defined?(Rails::Generators::NamedBase)
|
21
|
+
argument :name, type: :string, required: true, desc: "Prompt identifier, e.g., user_onboarding/welcome_email"
|
22
|
+
|
23
|
+
class_option :engine, type: :string, default: "erb", desc: "Template engine: erb or liquid"
|
24
|
+
class_option :locales, type: :array, default: [], desc: "Locales to generate (e.g., en es fr)"
|
25
|
+
class_option :no_locale, type: :boolean, default: false, desc: "Generate only fallback (no locale suffix)"
|
26
|
+
class_option :force, type: :boolean, default: false, desc: "Overwrite existing files"
|
27
|
+
|
28
|
+
def create_prompt_files
|
29
|
+
id_path = name.to_s
|
30
|
+
base_dir = File.join("app", "prompts", File.dirname(id_path))
|
31
|
+
basename = File.basename(id_path)
|
32
|
+
|
33
|
+
FileUtils.mkdir_p(base_dir)
|
34
|
+
|
35
|
+
ext = engine_extension
|
36
|
+
resolve_targets(basename, ext).each do |rel|
|
37
|
+
path = File.join(base_dir, rel)
|
38
|
+
if File.exist?(path) && !options[:force]
|
39
|
+
say_status :skip, path
|
40
|
+
next
|
41
|
+
end
|
42
|
+
create_file(path, content_for_engine(ext), force: true)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def engine_extension
|
49
|
+
engine = options[:engine].to_s.downcase
|
50
|
+
case engine
|
51
|
+
when "erb" then "erb"
|
52
|
+
when "liquid" then "liquid"
|
53
|
+
else
|
54
|
+
say_status :error, "Unknown engine '#{engine}'. Use erb or liquid.", :red
|
55
|
+
exit(1)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def resolved_locales
|
60
|
+
return [] if options[:no_locale]
|
61
|
+
|
62
|
+
if options[:locales].any?
|
63
|
+
options[:locales].map(&:to_s)
|
64
|
+
elsif defined?(I18n) && I18n.respond_to?(:available_locales)
|
65
|
+
Array(I18n.available_locales).map(&:to_s)
|
66
|
+
else
|
67
|
+
["en"]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def resolve_targets(basename, ext)
|
72
|
+
locs = resolved_locales
|
73
|
+
if options[:no_locale] || locs.empty?
|
74
|
+
["#{basename}.#{ext}"]
|
75
|
+
else
|
76
|
+
locs.uniq.map { |loc| "#{basename}.#{loc}.#{ext}" }
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def content_for_engine(ext)
|
81
|
+
case ext
|
82
|
+
when "erb"
|
83
|
+
<<~ERB
|
84
|
+
<!-- Prompt: generated by promptly:prompt -->
|
85
|
+
<!-- Identifier: #{name} -->
|
86
|
+
|
87
|
+
You are an AI assistant. Fill in content below using provided locals.
|
88
|
+
|
89
|
+
Context:
|
90
|
+
- Example local: <%= example || "value" %>
|
91
|
+
|
92
|
+
Task:
|
93
|
+
1. Explain the goal.
|
94
|
+
2. Provide actionable steps.
|
95
|
+
3. Keep it concise and clear.
|
96
|
+
ERB
|
97
|
+
when "liquid"
|
98
|
+
<<~LIQ
|
99
|
+
{# Prompt: generated by promptly:prompt #}
|
100
|
+
{# Identifier: #{name} #}
|
101
|
+
|
102
|
+
You are an AI assistant. Fill in content below using provided locals.
|
103
|
+
|
104
|
+
Context:
|
105
|
+
- Example local: {{ example | default: "value" }}
|
106
|
+
|
107
|
+
Task:
|
108
|
+
1. Explain the goal.
|
109
|
+
2. Provide actionable steps.
|
110
|
+
3. Keep it concise and clear.
|
111
|
+
LIQ
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "digest"
|
4
|
+
|
5
|
+
module Promptly
|
6
|
+
class Cache
|
7
|
+
class << self
|
8
|
+
attr_accessor :store, :enabled, :ttl
|
9
|
+
|
10
|
+
def configure
|
11
|
+
yield self
|
12
|
+
end
|
13
|
+
|
14
|
+
def enabled?
|
15
|
+
@enabled != false && store
|
16
|
+
end
|
17
|
+
|
18
|
+
def fetch(key, ttl: nil, &block)
|
19
|
+
return yield unless enabled?
|
20
|
+
|
21
|
+
cache_key = generate_key(key)
|
22
|
+
cached_value = store.read(cache_key)
|
23
|
+
|
24
|
+
if cached_value
|
25
|
+
cached_value
|
26
|
+
else
|
27
|
+
value = yield
|
28
|
+
store.write(cache_key, value, expires_in: ttl || self.ttl)
|
29
|
+
value
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def clear
|
34
|
+
return unless enabled? && store.respond_to?(:clear)
|
35
|
+
|
36
|
+
store.clear
|
37
|
+
end
|
38
|
+
|
39
|
+
def delete(key)
|
40
|
+
return unless enabled?
|
41
|
+
|
42
|
+
cache_key = generate_key(key)
|
43
|
+
store.delete(cache_key)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def generate_key(key_data)
|
49
|
+
case key_data
|
50
|
+
when String
|
51
|
+
"promptly:#{key_data}"
|
52
|
+
when Hash
|
53
|
+
content = key_data.sort.to_s
|
54
|
+
hash = Digest::SHA256.hexdigest(content)
|
55
|
+
"promptly:#{hash}"
|
56
|
+
else
|
57
|
+
"promptly:#{Digest::SHA256.hexdigest(key_data.to_s)}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Default configuration
|
63
|
+
self.enabled = true
|
64
|
+
self.ttl = 3600 # 1 hour default TTL
|
65
|
+
self.store = nil
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Promptly
|
4
|
+
module Helper
|
5
|
+
# Render a prompt template by identifier.
|
6
|
+
#
|
7
|
+
# Example:
|
8
|
+
# # app/prompts/welcome_email.erb
|
9
|
+
# # Hello <%= @user.name %>, welcome!
|
10
|
+
#
|
11
|
+
# # In a mailer, job, or service including this module:
|
12
|
+
# # render_prompt("welcome_email", user: @user)
|
13
|
+
#
|
14
|
+
# Supports locale-aware lookup and caching.
|
15
|
+
def render_prompt(identifier, locale: nil, cache: true, ttl: nil, **locals)
|
16
|
+
Promptly.render(identifier, locale: locale, locals: locals, cache: cache, ttl: ttl)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
data/lib/promptly/railtie.rb
CHANGED
@@ -6,6 +6,26 @@ module Promptly
|
|
6
6
|
# Intentionally minimal per DHH style; conventions over configuration
|
7
7
|
# Hook points will be added as features land.
|
8
8
|
Rails.logger.info("[promptly] loaded") if defined?(Rails.logger)
|
9
|
+
|
10
|
+
# Auto-configure Rails cache if available
|
11
|
+
if defined?(Rails.cache)
|
12
|
+
Promptly::Cache.store = Rails.cache
|
13
|
+
end
|
14
|
+
|
15
|
+
# Make render_prompt available in mailers, jobs, and controllers
|
16
|
+
if defined?(ActiveSupport)
|
17
|
+
ActiveSupport.on_load(:action_mailer) do
|
18
|
+
include Promptly::Helper
|
19
|
+
end
|
20
|
+
|
21
|
+
ActiveSupport.on_load(:active_job) do
|
22
|
+
include Promptly::Helper
|
23
|
+
end
|
24
|
+
|
25
|
+
ActiveSupport.on_load(:action_controller) do
|
26
|
+
include Promptly::Helper
|
27
|
+
end
|
28
|
+
end
|
9
29
|
end
|
10
30
|
|
11
31
|
rake_tasks do
|
data/lib/promptly/renderer.rb
CHANGED
@@ -26,6 +26,12 @@ module Promptly
|
|
26
26
|
lookup = ActionView::LookupContext.new(ActionView::PathSet.new([]))
|
27
27
|
av = view_class.new(lookup, {}, nil)
|
28
28
|
|
29
|
+
# Make locals available both as locals and instance variables (e.g., @user)
|
30
|
+
(locals || {}).each do |k, v|
|
31
|
+
ivar = "@#{k}"
|
32
|
+
av.instance_variable_set(ivar, v)
|
33
|
+
end
|
34
|
+
|
29
35
|
av.render(inline: template, type: :erb, locals: locals || {})
|
30
36
|
end
|
31
37
|
|
@@ -1,25 +1,143 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
namespace :ai_prompts do
|
4
|
-
desc "
|
5
|
-
task :
|
4
|
+
desc "Render a prompt: rake ai_prompts:render[identifier,locale]"
|
5
|
+
task :render, [:identifier, :locale] => :environment do |_, args|
|
6
6
|
identifier = args[:identifier]
|
7
7
|
locale = args[:locale]
|
8
8
|
prompts_path = ENV["PROMPTS_PATH"]
|
9
9
|
|
10
10
|
unless identifier
|
11
|
-
warn "Usage: rake ai_prompts:
|
11
|
+
warn "Usage: rake ai_prompts:render[identifier,locale]"
|
12
12
|
exit 1
|
13
13
|
end
|
14
14
|
|
15
15
|
begin
|
16
16
|
Promptly.prompts_path = prompts_path if prompts_path
|
17
17
|
|
18
|
-
output = Promptly.
|
18
|
+
output = Promptly.render(identifier, locale: locale)
|
19
19
|
puts output
|
20
20
|
rescue Promptly::Error => e
|
21
21
|
warn "Error: #{e.class}: #{e.message}"
|
22
22
|
exit 1
|
23
23
|
end
|
24
24
|
end
|
25
|
+
|
26
|
+
desc "Lint prompt templates. Usage: rake ai_prompts:lint[identifier] LOCALES=en,es REQUIRED=name,app_name PROMPTS_PATH=..."
|
27
|
+
task :lint, [:identifier] => :environment do |_, args|
|
28
|
+
require "erb"
|
29
|
+
|
30
|
+
prompts_path = ENV["PROMPTS_PATH"] || Promptly.prompts_path
|
31
|
+
identifier_filter = args[:identifier]
|
32
|
+
|
33
|
+
locales = if ENV["LOCALES"]
|
34
|
+
ENV["LOCALES"].split(",").map(&:strip).reject(&:empty?)
|
35
|
+
elsif defined?(I18n) && I18n.respond_to?(:available_locales)
|
36
|
+
I18n.available_locales.map(&:to_s)
|
37
|
+
else
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
|
41
|
+
required_keys = (ENV["REQUIRED"] || "").split(",").map(&:strip).reject(&:empty?)
|
42
|
+
|
43
|
+
unless File.directory?(prompts_path)
|
44
|
+
warn "[lint] prompts_path not found: #{prompts_path}"
|
45
|
+
exit 1
|
46
|
+
end
|
47
|
+
|
48
|
+
exts = Promptly::Locator::SUPPORTED_EXTS
|
49
|
+
|
50
|
+
files = Dir.glob(File.join(prompts_path, "**", "*{#{exts.join(",")}}"))
|
51
|
+
if identifier_filter
|
52
|
+
files.select! do |f|
|
53
|
+
# match by identifier path without locale/ext
|
54
|
+
rel = f.sub(/^#{Regexp.escape(prompts_path)}\//, "")
|
55
|
+
base = rel.sub(/\.(?:[a-z]{2})?(?:#{exts.map { |e| Regexp.escape(e) }.join("|")})\z/, "")
|
56
|
+
base == identifier_filter
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
if files.empty?
|
61
|
+
warn "[lint] No templates found under #{prompts_path}#{identifier_filter ? " for '#{identifier_filter}'" : ""}"
|
62
|
+
exit 1
|
63
|
+
end
|
64
|
+
|
65
|
+
status = 0
|
66
|
+
|
67
|
+
# Group by identifier (path without locale/ext)
|
68
|
+
grouped = files.group_by do |f|
|
69
|
+
rel = f.sub(/^#{Regexp.escape(prompts_path)}\//, "")
|
70
|
+
rel.sub(/\.(?:[a-z]{2})?(?:#{exts.map { |e| Regexp.escape(e) }.join("|")})\z/, "")
|
71
|
+
end
|
72
|
+
|
73
|
+
grouped.each do |identifier, paths|
|
74
|
+
puts "[lint] Identifier: #{identifier}"
|
75
|
+
|
76
|
+
# 1) Syntax check and placeholder scan per file
|
77
|
+
paths.each do |path|
|
78
|
+
engine = Promptly::Locator.engine_for(path)
|
79
|
+
content = File.read(path)
|
80
|
+
|
81
|
+
begin
|
82
|
+
case engine
|
83
|
+
when :erb
|
84
|
+
# Compile ERB to Ruby, don't execute
|
85
|
+
ERB.new(content)
|
86
|
+
when :liquid
|
87
|
+
if defined?(::Liquid)
|
88
|
+
::Liquid::Template.parse(content)
|
89
|
+
else
|
90
|
+
warn " - WARN: Liquid not available; skipping syntax parse for #{File.basename(path)}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
rescue => e
|
94
|
+
warn " - ERROR: Syntax error in #{File.basename(path)}: #{e.class}: #{e.message}"
|
95
|
+
status = 1
|
96
|
+
end
|
97
|
+
|
98
|
+
# Required placeholder presence (best-effort scan)
|
99
|
+
if required_keys.any?
|
100
|
+
missing = []
|
101
|
+
required_keys.each do |key|
|
102
|
+
present = false
|
103
|
+
case engine
|
104
|
+
when :erb
|
105
|
+
# naive checks: @key or key inside ERB output tags
|
106
|
+
present ||= content.match?(/<%[=\-].*?@#{Regexp.escape(key)}[\W]/m)
|
107
|
+
present ||= content.match?(/<%[=\-].*?\b#{Regexp.escape(key)}\b/m)
|
108
|
+
when :liquid
|
109
|
+
present ||= content.match?(/\{\{\s*#{Regexp.escape(key)}[\s\|\}]/)
|
110
|
+
end
|
111
|
+
missing << key unless present
|
112
|
+
end
|
113
|
+
if missing.any?
|
114
|
+
warn " - ERROR: Missing required placeholders in #{File.basename(path)}: #{missing.join(", ")}"
|
115
|
+
status = 1
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# 2) Missing locale files (if locales provided)
|
121
|
+
if locales.any?
|
122
|
+
found_locales = paths.map do |p|
|
123
|
+
# extract locale between name and extension: name.<locale>.ext
|
124
|
+
File.basename(p)[/\.([a-z]{2})\.(?:erb|liquid)\z/, 1]
|
125
|
+
end.compact.uniq
|
126
|
+
|
127
|
+
missing_locales = locales - found_locales
|
128
|
+
if missing_locales.any?
|
129
|
+
warn " - WARN: Missing locale templates for #{identifier}: #{missing_locales.join(", ")}"
|
130
|
+
else
|
131
|
+
puts " - OK: Locale coverage satisfied"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
if status.zero?
|
137
|
+
puts "[lint] OK"
|
138
|
+
else
|
139
|
+
warn "[lint] FAIL"
|
140
|
+
end
|
141
|
+
exit status
|
142
|
+
end
|
25
143
|
end
|
data/lib/promptly/version.rb
CHANGED
data/lib/promptly.rb
CHANGED
@@ -3,11 +3,13 @@
|
|
3
3
|
require_relative "promptly/version"
|
4
4
|
require_relative "promptly/renderer"
|
5
5
|
require_relative "promptly/locator"
|
6
|
+
require_relative "promptly/cache"
|
7
|
+
require_relative "promptly/helper"
|
6
8
|
|
7
9
|
module Promptly
|
8
10
|
class Error < StandardError; end
|
9
11
|
|
10
|
-
def self.
|
12
|
+
def self.render_template(template, locals: {}, engine: :erb)
|
11
13
|
Renderer.render(template, locals: locals, engine: engine)
|
12
14
|
end
|
13
15
|
|
@@ -20,10 +22,27 @@ module Promptly
|
|
20
22
|
@prompts_path = path
|
21
23
|
end
|
22
24
|
|
23
|
-
#
|
25
|
+
# Render a template by identifier using locator rules
|
24
26
|
# identifier: "user_onboarding/welcome"
|
25
27
|
# locale: defaults to I18n.locale when available
|
26
|
-
def self.
|
28
|
+
def self.render(identifier, locale: nil, locals: {}, cache: true, ttl: nil)
|
29
|
+
if cache && Cache.enabled?
|
30
|
+
cache_key = {
|
31
|
+
identifier: identifier,
|
32
|
+
locale: locale,
|
33
|
+
locals: locals,
|
34
|
+
prompts_path: prompts_path
|
35
|
+
}
|
36
|
+
|
37
|
+
Cache.fetch(cache_key, ttl: ttl) do
|
38
|
+
render_without_cache(identifier, locale: locale, locals: locals)
|
39
|
+
end
|
40
|
+
else
|
41
|
+
render_without_cache(identifier, locale: locale, locals: locals)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
private_class_method def self.render_without_cache(identifier, locale: nil, locals: {})
|
27
46
|
path = Locator.resolve(identifier, locale: locale)
|
28
47
|
raise Error, "Template not found for '#{identifier}' (locale: #{locale.inspect}) under #{prompts_path}" unless path
|
29
48
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: promptly
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.13
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Wilbur Suero
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-08-
|
11
|
+
date: 2025-08-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: actionview
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '7.
|
19
|
+
version: '7.2'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '7.
|
26
|
+
version: '7.2'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: rspec
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -66,6 +66,20 @@ dependencies:
|
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '5.5'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: railties
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '7.2'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '7.2'
|
69
83
|
description: Build maintainable, localized, and testable AI prompts using ERB or Liquid
|
70
84
|
templates with Rails conventions
|
71
85
|
email:
|
@@ -75,17 +89,20 @@ extensions: []
|
|
75
89
|
extra_rdoc_files: []
|
76
90
|
files:
|
77
91
|
- ".rspec"
|
92
|
+
- ".ruby-version"
|
78
93
|
- ".standard.yml"
|
79
94
|
- LICENSE
|
80
95
|
- README.md
|
81
96
|
- Rakefile
|
97
|
+
- lib/generators/promptly/prompt_generator.rb
|
82
98
|
- lib/promptly.rb
|
99
|
+
- lib/promptly/cache.rb
|
100
|
+
- lib/promptly/helper.rb
|
83
101
|
- lib/promptly/locator.rb
|
84
102
|
- lib/promptly/railtie.rb
|
85
103
|
- lib/promptly/renderer.rb
|
86
104
|
- lib/promptly/tasks/ai_prompts.rake
|
87
105
|
- lib/promptly/version.rb
|
88
|
-
- promptly.gemspec
|
89
106
|
homepage: https://github.com/wilburhimself/promptly
|
90
107
|
licenses:
|
91
108
|
- MIT
|
@@ -103,7 +120,10 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
103
120
|
requirements:
|
104
121
|
- - ">="
|
105
122
|
- !ruby/object:Gem::Version
|
106
|
-
version: 3.
|
123
|
+
version: '3.2'
|
124
|
+
- - "<"
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '3.4'
|
107
127
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
108
128
|
requirements:
|
109
129
|
- - ">="
|
data/promptly.gemspec
DELETED
@@ -1,44 +0,0 @@
|
|
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/wilburhimself/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/wilburhimself/promptly"
|
21
|
-
spec.metadata["changelog_uri"] = "https://github.com/wilburhimself/promptly/blob/main/CHANGELOG.md"
|
22
|
-
spec.metadata["documentation_uri"] = "https://github.com/wilburhimself/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
|